Compare commits

..

3 Commits

Author SHA1 Message Date
wuzihao051119
ab988f3be6 refactor: load support 2025-06-30 06:35:03 +08:00
wuzihao051119
b8dc1a4b1f refactor: zoom support 2025-06-30 03:07:33 +08:00
wuzihao051119
769d0aed87 refactor: asset manager 2025-06-29 04:20:20 +08:00
108 changed files with 22072 additions and 41042 deletions

View File

@@ -73,7 +73,10 @@ install_dependencies() {
log "Installing dependencies"
(
cd "${IMMICH_WORKSPACE}" || exit 1
CI=1 run_cmd make install-all
run_cmd make ci-server
run_cmd make ci-sdk
run_cmd make build-sdk
run_cmd make ci-web
)
log ""
}

View File

@@ -8,6 +8,11 @@ services:
- IMMICH_SERVER_URL=http://127.0.0.1:2283/
volumes: !override
- ..:/workspaces/immich
- cli_node_modules:/workspaces/immich/cli/node_modules
- e2e_node_modules:/workspaces/immich/e2e/node_modules
- open_api_node_modules:/workspaces/immich/open-api/typescript-sdk/node_modules
- server_node_modules:/workspaces/immich/server/node_modules
- web_node_modules:/workspaces/immich/web/node_modules
- ${UPLOAD_LOCATION:-upload1-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/workspaces/immich/server/upload
- ${UPLOAD_LOCATION:-upload2-devcontainer-volume}${UPLOAD_LOCATION:+/photos/upload}:/workspaces/immich/server/upload/upload
- /etc/localtime:/etc/localtime:ro

View File

@@ -10,9 +10,8 @@ cd "${IMMICH_WORKSPACE}/server" || (
exit 1
)
CI=1 pnpm install
while true; do
run_cmd pnpm exec nest start --debug "0.0.0.0:9230" --watch
run_cmd node ./node_modules/.bin/nest start --debug "0.0.0.0:9230" --watch
log "Nest API Server crashed with exit code $?. Respawning in 3s ..."
sleep 3
done

View File

@@ -16,7 +16,7 @@ until curl --output /dev/null --silent --head --fail "http://127.0.0.1:${IMMICH_
done
while true; do
run_cmd pnpm exec vite dev --host 0.0.0.0 --port "${DEV_PORT}"
run_cmd node ./node_modules/.bin/vite dev --host 0.0.0.0 --port "${DEV_PORT}"
log "Web crashed with exit code $?. Respawning in 3s ..."
sleep 3
done

View File

@@ -4,7 +4,6 @@
design/
docker/
Dockerfile
!docker/scripts
docs/
!docs/package.json

View File

@@ -60,14 +60,14 @@ jobs:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Run install
run: pnpm install
- name: Run npm install
run: npm ci
- name: Check formatting
run: pnpm format
run: npm run format
- name: Run build
run: pnpm build
run: npm run build
- name: Upload build output
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2

View File

@@ -80,33 +80,30 @@ jobs:
with:
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Run package manager install
run: pnpm install
- name: Run npm install
run: npm ci
- name: Run linter
run: pnpm lint
run: npm run lint
if: ${{ !cancelled() }}
- name: Run formatter
run: pnpm format
run: npm run format
if: ${{ !cancelled() }}
- name: Run tsc
run: pnpm check
run: npm run check
if: ${{ !cancelled() }}
- name: Run small tests & coverage
run: pnpm test
run: npm test
if: ${{ !cancelled() }}
cli-unit-tests:
@@ -126,37 +123,34 @@ jobs:
with:
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './cli/.nvmrc'
cache: 'pnpm'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Setup typescript-sdk
run: pnpm install && pnpm run build
run: npm ci && npm run build
working-directory: ./open-api/typescript-sdk
- name: Install deps
run: pnpm install
run: npm ci
- name: Run linter
run: pnpm lint
run: npm run lint
if: ${{ !cancelled() }}
- name: Run formatter
run: pnpm format
run: npm run format
if: ${{ !cancelled() }}
- name: Run tsc
run: pnpm check
run: npm run check
if: ${{ !cancelled() }}
- name: Run unit tests & coverage
run: pnpm test
run: npm run test
if: ${{ !cancelled() }}
cli-unit-tests-win:
@@ -176,30 +170,27 @@ jobs:
with:
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './cli/.nvmrc'
cache: 'pnpm'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Setup typescript-sdk
run: pnpm install --frozen-lockfile && pnpm build
run: npm ci && npm run build
working-directory: ./open-api/typescript-sdk
- name: Install deps
run: pnpm install --frozen-lockfile
run: npm ci
# Skip linter & formatter in Windows test.
- name: Run tsc
run: pnpm check
run: npm run check
if: ${{ !cancelled() }}
- name: Run unit tests & coverage
run: pnpm test
run: npm run test
if: ${{ !cancelled() }}
web-lint:
@@ -219,33 +210,30 @@ jobs:
with:
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './web/.nvmrc'
cache: 'pnpm'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Run setup typescript-sdk
run: pnpm install --frozen-lockfile && pnpm build
run: npm ci && npm run build
working-directory: ./open-api/typescript-sdk
- name: Run npm install
run: pnpm install --frozen-lockfile
run: npm ci
- name: Run linter
run: pnpm lint:p
run: npm run lint:p
if: ${{ !cancelled() }}
- name: Run formatter
run: pnpm format
run: npm run format
if: ${{ !cancelled() }}
- name: Run svelte checks
run: pnpm check:svelte
run: npm run check:svelte
if: ${{ !cancelled() }}
web-unit-tests:
@@ -265,29 +253,26 @@ jobs:
with:
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './web/.nvmrc'
cache: 'pnpm'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Run setup typescript-sdk
run: pnpm install --frozen-lockfile && pnpm build
run: npm ci && npm run build
working-directory: ./open-api/typescript-sdk
- name: Run npm install
run: pnpm install --frozen-lockfile
run: npm ci
- name: Run tsc
run: pnpm check:typescript
run: npm run check:typescript
if: ${{ !cancelled() }}
- name: Run unit tests & coverage
run: pnpm test
run: npm run test
if: ${{ !cancelled() }}
i18n-tests:
@@ -303,21 +288,18 @@ jobs:
with:
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './web/.nvmrc'
cache: 'pnpm'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Install dependencies
run: pnpm --filter=immich-web install --frozen-lockfile
run: npm --prefix=web ci
- name: Format
run: pnpm --filter=immich-web format:i18n
run: npm --prefix=web run format:i18n
- name: Find file changes
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
@@ -352,35 +334,32 @@ jobs:
with:
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Run setup typescript-sdk
run: pnpm install --frozen-lockfile && pnpm build
run: npm ci && npm run build
working-directory: ./open-api/typescript-sdk
if: ${{ !cancelled() }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
run: npm ci
if: ${{ !cancelled() }}
- name: Run linter
run: pnpm lint
run: npm run lint
if: ${{ !cancelled() }}
- name: Run formatter
run: pnpm format
run: npm run format
if: ${{ !cancelled() }}
- name: Run tsc
run: pnpm check
run: npm run check
if: ${{ !cancelled() }}
server-medium-tests:
@@ -400,21 +379,18 @@ jobs:
with:
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Run npm install
run: pnpm install --frozen-lockfile
run: npm ci
- name: Run medium tests
run: pnpm test:medium
run: npm run test:medium
if: ${{ !cancelled() }}
e2e-tests-server-cli:
@@ -438,28 +414,25 @@ jobs:
persist-credentials: false
submodules: 'recursive'
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Run setup typescript-sdk
run: pnpm install --frozen-lockfile && pnpm build
run: npm ci && npm run build
working-directory: ./open-api/typescript-sdk
if: ${{ !cancelled() }}
- name: Run setup cli
run: pnpm install --frozen-lockfile && pnpm build
run: npm ci && npm run build
working-directory: ./cli
if: ${{ !cancelled() }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
run: npm ci
if: ${{ !cancelled() }}
- name: Docker build
@@ -467,7 +440,7 @@ jobs:
if: ${{ !cancelled() }}
- name: Run e2e tests (api & cli)
run: pnpm test
run: npm run test
if: ${{ !cancelled() }}
e2e-tests-web:
@@ -491,23 +464,20 @@ jobs:
persist-credentials: false
submodules: 'recursive'
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Run setup typescript-sdk
run: pnpm install --frozen-lockfile && pnpm build
run: npm ci && npm run build
working-directory: ./open-api/typescript-sdk
if: ${{ !cancelled() }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
run: npm ci
if: ${{ !cancelled() }}
- name: Install Playwright Browsers
@@ -614,21 +584,18 @@ jobs:
with:
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './.github/.nvmrc'
cache: 'pnpm'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Run npm install
run: pnpm install --frozen-lockfile
run: npm ci
- name: Run formatter
run: pnpm format
run: npm run format
if: ${{ !cancelled() }}
shellcheck:
@@ -660,21 +627,18 @@ jobs:
with:
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Install server dependencies
run: pnpm --filter immich install --frozen-lockfile
run: npm --prefix=server ci
- name: Build the app
run: pnpm --filter immich build
run: npm --prefix=server run build
- name: Run API generation
run: make open-api
@@ -726,31 +690,28 @@ jobs:
with:
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Install server dependencies
run: pnpm install --frozen-lockfile
run: npm ci
- name: Build the app
run: pnpm build
run: npm run build
- name: Run existing migrations
run: pnpm migrations:run
run: npm run migrations:run
- name: Test npm run schema:reset command works
run: pnpm schema:reset
run: npm run schema:reset
- name: Generate new migrations
continue-on-error: true
run: pnpm migrations:generate src/TestMigration
run: npm run migrations:generate src/TestMigration
- name: Find file changes
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
@@ -769,7 +730,7 @@ jobs:
exit 1
- name: Run SQL generation
run: pnpm sync:sql
run: npm run sync:sql
env:
DB_URL: postgres://postgres:postgres@localhost:5432/immich

View File

@@ -34,52 +34,41 @@ open-api-typescript:
cd ./open-api && bash ./bin/generate-open-api.sh typescript
sql:
pnpm --filter immich run sync:sql
npm --prefix server run sync:sql
attach-server:
docker exec -it docker_immich-server_1 sh
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 docs .github
# package names mapping function
# cli = @immich/cli
# docs = documentation
# e2e = immich-e2e
# open-api/typescript-sdk = @immich/sdk
# server = immich
# web = immich-web
map-package = $(subst sdk,@immich/sdk,$(subst cli,@immich/cli,$(subst docs,documentation,$(subst e2e,immich-e2e,$(subst server,immich,$(subst web,immich-web,$1))))))
audit-%:
pnpm --filter $(call map-package,$*) audit fix
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) audit fix
install-%:
pnpm --filter $(call map-package,$*) install
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) i
ci-%:
pnpm --filter $(call map-package,$*) install --frozen-lockfile
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) ci
build-cli: build-sdk
build-web: build-sdk
build-%: install-%
pnpm --filter $(call map-package,$*) run build
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) run build
format-%:
pnpm --filter $(call map-package,$*) run format:fix
npm --prefix $* run format:fix
lint-%:
pnpm --filter $(call map-package,$*) run lint:fix
lint-web:
pnpm --filter $(call map-package,$*) run lint:p
npm --prefix $* run lint:fix
check-%:
pnpm --filter $(call map-package,$*) run check
npm --prefix $* run check
check-web:
pnpm --filter immich-web run check:typescript
pnpm --filter immich-web run check:svelte
npm --prefix web run check:typescript
npm --prefix web run check:svelte
test-%:
pnpm --filter $(call map-package,$*) run test
npm --prefix $* run test
test-e2e:
docker compose -f ./e2e/docker-compose.yml build
pnpm --filter immich-e2e run test
pnpm --filter immich-e2e run test:web
npm --prefix e2e run test
npm --prefix e2e run test:web
test-medium:
docker run \
--rm \
@@ -89,39 +78,24 @@ test-medium:
-v ./server/tsconfig.json:/usr/src/app/tsconfig.json \
-e NODE_ENV=development \
immich-server:latest \
-c "pnpm test:medium -- --run"
-c "npm ci && npm run test:medium -- --run"
test-medium-dev:
docker exec -it immich_server /bin/sh -c "pnpm run test:medium"
docker exec -it immich_server /bin/sh -c "npm run test:medium"
install-all:
pnpm -r --filter '!documentation' install
ci-all:
pnpm -r --filter '!documentation' install --frozen-lockfile
build-all: $(foreach M,$(filter-out e2e docs .github,$(MODULES)),build-$M) ;
check-all:
pnpm -r --filter '!documentation' run "/^(check|check\:svelte|check\:typescript)$/"
lint-all:
pnpm -r --filter '!documentation' run lint:fix
format-all:
pnpm -r --filter '!documentation' run format:fix
audit-all:
pnpm -r --filter '!documentation' audit fix
hygiene-all: audit-all
pnpm -r --filter '!documentation' run "/(format:fix|check|check:svelte|check:typescript|sql)/"
test-all:
pnpm -r --filter '!documentation' run "/^test/"
prune:
pnpm store prune
build-all: $(foreach M,$(filter-out e2e .github,$(MODULES)),build-$M) ;
install-all: $(foreach M,$(MODULES),install-$M) ;
ci-all: $(foreach M,$(filter-out .github,$(MODULES)),ci-$M) ;
check-all: $(foreach M,$(filter-out sdk cli docs .github,$(MODULES)),check-$M) ;
lint-all: $(foreach M,$(filter-out sdk docs .github,$(MODULES)),lint-$M) ;
format-all: $(foreach M,$(filter-out sdk,$(MODULES)),format-$M) ;
audit-all: $(foreach M,$(MODULES),audit-$M) ;
hygiene-all: lint-all format-all check-all sql audit-all;
test-all: $(foreach M,$(filter-out sdk docs .github,$(MODULES)),test-$M) ;
clean:
find . -name "node_modules" -type d -prune -exec rm -rf {} +
find . -name "node_modules" -type d -prune -exec rm -rf '{}' +
find . -name "dist" -type d -prune -exec rm -rf '{}' +
find . -name "build" -type d -prune -exec rm -rf '{}' +
find . -name "svelte-kit" -type d -prune -exec rm -rf '{}' +
command -v docker >/dev/null 2>&1 && docker compose -f ./docker/docker-compose.dev.yml rm -v -f || true
command -v docker >/dev/null 2>&1 && docker compose -f ./e2e/docker-compose.yml rm -v -f || true
docker compose -f ./docker/docker-compose.dev.yml rm -v -f || true
docker compose -f ./e2e/docker-compose.yml rm -v -f || true

View File

@@ -6,10 +6,8 @@ Please see the [Immich CLI documentation](https://immich.app/docs/features/comma
Before building the CLI, you must build the immich server and the open-api client. To build the server run the following in the server folder:
# if you don't have node installed
$ npm install -g pnpm
$ pnpm install
$ pnpm build
$ npm install
$ npm run build
Then, to build the open-api client run the following in the open-api folder:
@@ -17,10 +15,8 @@ Then, to build the open-api client run the following in the open-api folder:
To run the Immich CLI from source, run the following in the cli folder:
# if you don't have node installed
$ npm install -g pnpm
$ pnpm install
$ pnpm build
$ npm install
$ npm run build
$ ts-node .
You'll need ts-node, the easiest way to install it is to use npm:

View File

@@ -1,2 +0,0 @@
#!/usr/bin/env node
require('../dist/index.js');

View File

@@ -5,7 +5,7 @@
"type": "module",
"exports": "./dist/index.js",
"bin": {
"immich": "./bin/immich"
"immich": "dist/index.js"
},
"license": "GNU Affero General Public License version 3",
"keywords": [

2825
cli/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,22 +0,0 @@
# You can find documentation for all the supported env variables at https://immich.app/docs/install/environment-variables
# The location where your uploaded files are stored
UPLOAD_LOCATION=/LUNA/ALPHA/MEDIA/PHOTOS/immich_dev_library
# The location where your database files are stored. Network shares are not supported for the database
DB_DATA_LOCATION=./postgres
# To set a timezone, uncomment the next line and change Etc/UTC to a TZ identifier from this list: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List
# TZ=Etc/UTC
# The Immich version to use. You can pin this to a specific version like "v1.71.0"
IMMICH_VERSION=release
# Connection secret for postgres. You should change it to a random password
# Please use only the characters `A-Za-z0-9`, without special characters or spaces
DB_PASSWORD=postgres
# The values below this line do not need to be changed
###################################################################################
DB_USERNAME=postgres
DB_DATABASE_NAME=immich

View File

@@ -24,11 +24,11 @@ services:
build:
context: ../
dockerfile: server/Dockerfile
target: dev-docker
target: dev
restart: unless-stopped
volumes:
- ../server:/usr/src/app
- ../open-api/:/usr/src/open-api
- ../open-api:/usr/src/open-api
- ${UPLOAD_LOCATION}/photos:/usr/src/app/upload
- ${UPLOAD_LOCATION}/photos/upload:/usr/src/app/upload/upload
- /usr/src/app/node_modules
@@ -69,8 +69,7 @@ services:
# Needed for rootless docker setup, see https://github.com/moby/moby/issues/45919
# user: 0:0
build:
context: ../
dockerfile: web/Dockerfile
context: ../web
command: ['/usr/src/app/bin/immich-web']
env_file:
- .env
@@ -80,7 +79,7 @@ services:
volumes:
- ../web:/usr/src/app
- ../i18n:/usr/src/i18n
- ../open-api/:/usr/src/open-api
- ../open-api/:/usr/src/open-api/
# - ../../ui:/usr/ui
- /usr/src/app/node_modules
ulimits:

View File

@@ -5,7 +5,7 @@ This website is built using [Docusaurus](https://docusaurus.io/), a modern stati
### Installation
```
$ pnpm install
$ npm install
```
### Local Development

View File

@@ -150,10 +150,12 @@ for more info read the [release notes](https://github.com/immich-app/immich/rele
- Preview images (small thumbnails and large previews) for each asset and thumbnails for recognized faces.
- Stored in `UPLOAD_LOCATION/thumbs/<userID>`.
- **Encoded Assets:**
- Videos that have been re-encoded from the original for wider compatibility. The original is not removed.
- Stored in `UPLOAD_LOCATION/encoded-video/<userID>`.
- **Postgres**
- The Immich database containing all the information to allow the system to function properly.
**Note:** This folder will only appear to users who have made the changes mentioned in [v1.102.0](https://github.com/immich-app/immich/discussions/8930) (an optional, non-mandatory change) or who started with this version.
- Stored in `DB_DATA_LOCATION`.
@@ -199,6 +201,7 @@ When you turn off the storage template engine, it will leave the assets in `UPLO
- Temporarily located in `UPLOAD_LOCATION/upload/<userID>`.
- Transferred to `UPLOAD_LOCATION/library/<userID>` upon successful upload.
- **Postgres**
- The Immich database containing all the information to allow the system to function properly.
**Note:** This folder will only appear to users who have made the changes mentioned in [v1.102.0](https://github.com/immich-app/immich/discussions/8930) (an optional, non-mandatory change) or who started with this version.
- Stored in `DB_DATA_LOCATION`.

View File

@@ -20,6 +20,7 @@ Immich supports 3rd party authentication via [OpenID Connect][oidc] (OIDC), an i
Before enabling OAuth in Immich, a new client application needs to be configured in the 3rd-party authentication server. While the specifics of this setup vary from provider to provider, the general approach should be the same.
1. Create a new (Client) Application
1. The **Provider** type should be `OpenID Connect` or `OAuth2`
2. The **Client type** should be `Confidential`
3. The **Application** type should be `Web`
@@ -28,6 +29,7 @@ Before enabling OAuth in Immich, a new client application needs to be configured
2. Configure Redirect URIs/Origins
The **Sign-in redirect URIs** should include:
- `app.immich:///oauth-callback` - for logging in with OAuth from the [Mobile App](/docs/features/mobile-app.mdx)
- `http://DOMAIN:PORT/auth/login` - for logging in with OAuth from the Web Client
- `http://DOMAIN:PORT/user-settings` - for manually linking OAuth in the Web Client
@@ -35,17 +37,21 @@ Before enabling OAuth in Immich, a new client application needs to be configured
Redirect URIs should contain all the domains you will be using to access Immich. Some examples include:
Mobile
- `app.immich:///oauth-callback` (You **MUST** include this for iOS and Android mobile apps to work properly)
Localhost
- `http://localhost:2283/auth/login`
- `http://localhost:2283/user-settings`
Local IP
- `http://192.168.0.200:2283/auth/login`
- `http://192.168.0.200:2283/user-settings`
Hostname
- `https://immich.example.com/auth/login`
- `https://immich.example.com/user-settings`

View File

@@ -199,11 +199,13 @@ To use your SSH key for commit signing, see the [GitHub guide on SSH commit sign
When the Dev Container starts, it automatically:
1. **Runs post-create script** (`container-server-post-create.sh`):
- Adjusts file permissions for the `node` user
- Installs dependencies: `pnpm install` in all packages
- Installs dependencies: `npm install` in all packages
- Builds TypeScript SDK: `npm run build` in `open-api/typescript-sdk`
2. **Starts development servers** via VS Code tasks:
- `Immich API Server (Nest)` - API server with hot-reloading on port 2283
- `Immich Web Server (Vite)` - Web frontend with hot-reloading on port 3000
- Both servers watch for file changes and recompile automatically
@@ -333,12 +335,14 @@ make install-all # Install all dependencies
The Dev Container is pre-configured for debugging:
1. **API Server Debugging**:
- Set breakpoints in VS Code
- Press `F5` or use "Run and Debug" panel
- Select "Attach to Server" configuration
- Debug port: 9231
2. **Worker Debugging**:
- Use "Attach to Workers" configuration
- Debug port: 9230
@@ -424,6 +428,7 @@ While the Dev Container focuses on server and web development, you can connect m
```
2. **Configure mobile app**:
- Server URL: `http://YOUR_IP:2283/api`
- Ensure firewall allows port 2283

View File

@@ -56,7 +56,7 @@ If you only want to do web development connected to an existing, remote backend,
1. Build the Immich SDK - `cd open-api/typescript-sdk && npm i && npm run build && cd -`
2. Enter the web directory - `cd web/`
3. Install web dependencies - `pnpm i`
3. Install web dependencies - `npm i`
4. Start the web development server
```bash

View File

@@ -5,7 +5,7 @@
### Unit tests
Unit are run by calling `npm run test` from the `server/` directory.
You need to run `pnpm install` (in `server/`) before _once_.
You need to run `npm install` (in `server/`) before _once_.
### End to end tests

View File

@@ -75,6 +75,7 @@ alt="Select Plugins > Compose.Manager > Add New Stack > Label it Immich"
5. Click "**Save Changes**", you will be prompted to edit stack UI labels, just leave this blank and click "**Ok**"
6. Select the cog ⚙️ next to Immich, click "**Edit Stack**", then click "**Env File**"
7. Paste the entire contents of the [Immich example.env](https://github.com/immich-app/immich/releases/latest/download/example.env) file into the Unraid editor, then **before saving** edit the following:
- `UPLOAD_LOCATION`: Create a folder in your Images Unraid share and place the **absolute** location here > For example my _"images"_ share has a folder within it called _"immich"_. If I browse to this directory in the terminal and type `pwd` the output is `/mnt/user/images/immich`. This is the exact value I need to enter as my `UPLOAD_LOCATION`
- `DB_DATA_LOCATION`: Change this to use an Unraid share (preferably a cache pool, e.g. `/mnt/user/appdata/postgresql/data`). This uses the `appdata` share. Do also create the `postgresql` folder, by running `mkdir /mnt/user/{share_location}/postgresql/data`. If left at default it will try to use Unraid's `/boot/config/plugins/compose.manager/projects/[stack_name]/postgres` folder which it doesn't have permissions to, resulting in this container continuously restarting.

20954
docs/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -18,7 +18,6 @@
"dependencies": {
"@docusaurus/core": "~3.7.0",
"@docusaurus/preset-classic": "~3.7.0",
"@docusaurus/theme-common": "~3.7.0",
"@mdi/js": "^7.3.67",
"@mdi/react": "^1.6.1",
"@mdx-js/react": "^3.0.0",
@@ -27,7 +26,6 @@
"clsx": "^2.0.0",
"docusaurus-lunr-search": "^3.3.2",
"docusaurus-preset-openapi": "^0.7.5",
"lunr": "^2.3.9",
"postcss": "^8.4.25",
"prism-react-renderer": "^2.3.1",
"raw-loader": "^4.0.2",

13672
docs/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

4470
e2e/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@ A TypeScript SDK for interfacing with the [Immich](https://immich.app/) API.
## Install
```bash
pnpm i --save @immich/sdk
npm i --save @immich/sdk
```
## Usage

View File

@@ -29,6 +29,5 @@
},
"volta": {
"node": "22.16.0"
},
"packageManager": "pnpm@10.12.3+sha512.467df2c586056165580ad6dfb54ceaad94c5a30f80893ebdec5a44c5aa73c205ae4a5bb9d5ed6bb84ea7c249ece786642bbb49d06a307df218d03da41c317417"
}
}

View File

@@ -1,50 +0,0 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
packageExtensionsChecksum: sha256-da8GREkR2VnR5zDxp+RNh2YOpcUGfK6mRCcdi/oaiJs=
importers:
.:
dependencies:
'@oazapfts/runtime':
specifier: ^1.0.2
version: 1.0.4
devDependencies:
'@types/node':
specifier: ^22.15.32
version: 22.15.32
typescript:
specifier: ^5.3.3
version: 5.8.3
packages:
'@oazapfts/runtime@1.0.4':
resolution: {integrity: sha512-7t6C2shug/6tZhQgkCa532oTYBLEnbASV/i1SG1rH2GB4h3aQQujYciYSPT92hvN4IwTe8S2hPkN/6iiOyTlCg==}
'@types/node@22.15.32':
resolution: {integrity: sha512-3jigKqgSjsH6gYZv2nEsqdXfZqIFGAV36XYYjf9KGZ3PSG+IhLecqPnI310RvjutyMwifE2hhhNEklOUrvx/wA==}
typescript@5.8.3:
resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==}
engines: {node: '>=14.17'}
hasBin: true
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
snapshots:
'@oazapfts/runtime@1.0.4': {}
'@types/node@22.15.32':
dependencies:
undici-types: 6.21.0
typescript@5.8.3: {}
undici-types@6.21.0: {}

15
package-lock.json generated
View File

@@ -1,15 +0,0 @@
{
"name": "immich-monorepo",
"version": "0.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-monorepo",
"version": "0.0.1",
"engines": {
"pnpm": ">=10.0.0"
}
}
}
}

View File

@@ -1,10 +0,0 @@
{
"name": "immich-monorepo",
"version": "0.0.1",
"description": "monorepo for immich and friends",
"private": true,
"packageManager": "pnpm@10.12.3+sha512.467df2c586056165580ad6dfb54ceaad94c5a30f80893ebdec5a44c5aa73c205ae4a5bb9d5ed6bb84ea7c249ece786642bbb49d06a307df218d03da41c317417",
"engines": {
"pnpm": ">=10.0.0"
}
}

11
pnpm-lock.yaml generated
View File

@@ -1,11 +0,0 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
packageExtensionsChecksum: sha256-da8GREkR2VnR5zDxp+RNh2YOpcUGfK6mRCcdi/oaiJs=
importers:
.: {}

View File

@@ -1,45 +0,0 @@
packages:
- cli
- docs
- e2e
- open-api/typescript-sdk
- server
- web
dedupePeerDependents: false
ignoredBuiltDependencies:
- '@tailwindcss/oxide'
- canvas
- es5-ext
- esbuild
- '@nestjs/core'
- '@scarf/scarf'
- '@swc/core'
- bcrypt
- cpu-features
- msgpackr-extract
- protobufjs
- ssh2
- utimes
onlyBuiltDependencies:
- sharp
packageExtensions:
# these packages use tslib, but do not declare it as a dependency
nestjs-kysely:
dependencies:
tslib: '*'
nestjs-otel:
dependencies:
tslib: '*'
sharp:
dependencies:
'@img/sharp-libvips-linux-x64': '*'
'@img/sharp-libvips-linux-arm64': '*'
preferWorkspacePackages: true
shamefullyHoist: true
sharedWorkspaceLockfile: false

View File

@@ -1,101 +1,48 @@
# dev build
FROM ghcr.io/immich-app/base-server-dev:202505131114@sha256:cf4507bbbf307e9b6d8ee9418993321f2b85867da8ce14d0a20ccaf9574cb995 AS dev
RUN apt-get install --no-install-recommends -yqq tini
WORKDIR /usr/src/app
COPY server/package.json server/package-lock.json ./
COPY server/patches ./patches
RUN npm ci && \
# exiftool-vendored.pl, sharp-linux-x64 and sharp-linux-arm64 are the only ones we need
# they're marked as optional dependencies, so we need to copy them manually after pruning
rm -rf node_modules/@img/sharp-libvips* && \
rm -rf node_modules/@img/sharp-linuxmusl-x64
ENV PATH="${PATH}:/usr/src/app/bin" \
IMMICH_ENV=development \
NVIDIA_DRIVER_CAPABILITIES=all \
NVIDIA_VISIBLE_DEVICES=all \
COREPACK_ENABLE_AUTO_PIN=0 \
COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
npm_config_devdir=/buildcache/node_gyp
RUN corepack enable && \
corepack install -g pnpm && \
apt-get install --no-install-recommends -yqq tini
RUN mkdir -p /buildcache/pnpm_store && \
chown -R node:node /buildcache && \
mkdir -p /usr/local/etc && \
echo "store-dir=/buildcache/pnpm_store" >> /usr/local/etc/npmrc
RUN rm -rf /usr/src/app && \
mkdir -p /usr/src/app && \
chown node:node /usr/src/app
USER node
WORKDIR /usr/src/app
COPY --chown=node:node \
server/package.json \
server/pnpm-lock.yaml \
pnpm-workspace.yaml \
./
RUN pnpm fetch
NVIDIA_VISIBLE_DEVICES=all
ENTRYPOINT ["tini", "--", "/bin/sh"]
FROM dev AS dev-docker
RUN pnpm install
FROM dev AS dev-container-server
USER root
RUN rm -rf /usr/src/app
RUN apt-get update && \
apt-get install sudo inetutils-ping openjdk-11-jre-headless \
vim nano -y --no-install-recommends --fix-missing
vim nano \
-y --no-install-recommends --fix-missing
RUN usermod -aG sudo node && \
echo "node ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers
RUN usermod -aG sudo node
RUN echo "node ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers
RUN mkdir -p /workspaces/immich
RUN chown node -R /workspaces
COPY --chown=node:node --chmod=777 ../.devcontainer/server/*.sh /immich-devcontainer/
USER node
COPY --chown=node:node .. /tmp/create-dep-cache/
WORKDIR /tmp/create-dep-cache
RUN make ci-all && rm -rf /tmp/create-dep-cache
RUN sudo mkdir -p /workspaces/immich && \
sudo chown node -R /workspaces && \
sudo mkdir /immich-devcontainer && \
sudo chown node -R /immich-devcontainer
COPY --chmod=777 \
../.devcontainer/server/*.sh \
/immich-devcontainer/
COPY --chown=node:node \
package.json \
pnpm-lock.yaml \
./
# note: e2e is part of dockerignore, so it is not copied here
COPY --chown=node:node \
web/package.json \
web/pnpm-lock.yaml \
./web/
COPY --chown=node:node \
cli/package.json \
cli/pnpm-lock.yaml \
./cli/
COPY --chown=node:node \
server/package.json \
server/pnpm-lock.yaml \
./server/
# note: docs is part of dockerignore, so it is not copied here
COPY --chown=node:node open-api/typescript-sdk/package.json \
open-api/typescript-sdk/pnpm-lock.yaml \
./open-api/typescript-sdk/
# This will cache all dependencies
RUN sudo rm -rf /buildcache && mkdir -p /buildcache/pnpm_store
RUN pnpm install --frozen-lockfile
WORKDIR /workspaces/immich
# Remove app dir from dev container
RUN sudo rm -rf /usr/src/app
FROM dev-container-server AS dev-container-mobile
USER root
# Enable multiarch for arm64 if necessary
RUN if [ "$(dpkg --print-architecture)" = "arm64" ]; then \
sudo dpkg --add-architecture amd64 && \
sudo apt-get install -y --no-install-recommends \
dpkg --add-architecture amd64 && \
apt-get update && \
apt-get install -y --no-install-recommends \
qemu-user-static \
libc6:amd64 \
libstdc++6:amd64 \
@@ -110,13 +57,15 @@ ENV FLUTTER_HOME=/flutter
ENV PATH=${PATH}:${FLUTTER_HOME}/bin
# Flutter SDK
RUN sudo mkdir -p ${FLUTTER_HOME} \
&& sudo curl -C - --output flutter.tar.xz https://storage.googleapis.com/flutter_infra_release/releases/${FLUTTER_CHANNEL}/linux/flutter_linux_${FLUTTER_VERSION}-${FLUTTER_CHANNEL}.tar.xz \
&& sudo tar -xf flutter.tar.xz --strip-components=1 -C ${FLUTTER_HOME} \
&& sudo rm flutter.tar.xz \
&& sudo chown -R node ${FLUTTER_HOME}
RUN mkdir -p ${FLUTTER_HOME} \
&& curl -C - --output flutter.tar.xz https://storage.googleapis.com/flutter_infra_release/releases/${FLUTTER_CHANNEL}/linux/flutter_linux_${FLUTTER_VERSION}-${FLUTTER_CHANNEL}.tar.xz \
&& tar -xf flutter.tar.xz --strip-components=1 -C ${FLUTTER_HOME} \
&& rm flutter.tar.xz \
&& chown -R node ${FLUTTER_HOME}
RUN wget -qO- https://dcm.dev/pgp-key.public | sudo gpg --dearmor -o /usr/share/keyrings/dcm.gpg \
USER node
RUN sudo apt-get update \
&& wget -qO- https://dcm.dev/pgp-key.public | sudo gpg --dearmor -o /usr/share/keyrings/dcm.gpg \
&& echo 'deb [signed-by=/usr/share/keyrings/dcm.gpg arch=amd64] https://dcm.dev/debian stable main' | sudo tee /etc/apt/sources.list.d/dart_stable.list \
&& sudo apt-get update \
&& sudo apt-get install dcm -y
@@ -125,81 +74,49 @@ COPY --chmod=777 ../.devcontainer/mobile/container-mobile-post-create.sh /immich
RUN dart --disable-analytics
# server production build
FROM dev AS prod
USER root
RUN chown node:node /usr/src/app
USER node
COPY --chown=node:node server .
RUN pnpm install --frozen-lockfile --offline && \
pnpm build
COPY server .
RUN npm run build
RUN npm prune --omit=dev --omit=optional
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
FROM dev AS sdk
# web build
FROM node:22.16.0-alpine3.20@sha256:2289fb1fba0f4633b08ec47b94a89c7e20b829fc5679f9b7b298eaa2f1ed8b7e AS web
COPY --chown=node:node open-api/typescript-sdk/ .
RUN pnpm install --frozen-lockfile --no-optional && \
pnpm build
WORKDIR /usr/src/open-api/typescript-sdk
COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./
RUN npm ci
COPY open-api/typescript-sdk/ ./
RUN npm run build
# web production build
FROM dev AS web
WORKDIR /usr/src/app
COPY web/package*.json web/svelte.config.js ./
RUN npm ci
COPY web ./
COPY i18n ../i18n
RUN npm run build
COPY --chown=node:node web .
COPY --from=sdk /usr/src/app /usr/src/open-api/typescript-sdk
COPY --chown=node:node i18n /usr/src/i18n
RUN pnpm install --frozen-lockfile && \
pnpm build
FROM dev AS cli
COPY --chown=node:node cli .
COPY --from=sdk /usr/src/app /usr/src/open-api/typescript-sdk
# the following command does not use --offline, because the cache created in
# the 'dev' stage did not includ the cli depenencies
RUN pnpm install --frozen-lockfile && \
pnpm build
# prod build
FROM ghcr.io/immich-app/base-server-prod:202505061115@sha256:9971d3a089787f0bd01f4682141d3665bcf5efb3e101a88e394ffd25bee4eedb
RUN corepack enable && \
corepack install -g pnpm
WORKDIR /usr/src/app
ENV NODE_ENV=production \
NVIDIA_DRIVER_CAPABILITIES=all \
NVIDIA_VISIBLE_DEVICES=all \
npm_config_devdir=/buildcache/node_gyp \
COREPACK_ENABLE_DOWNLOAD_PROMPT=0
RUN mkdir -p /buildcache/pnpm_store && \
chown -R node:node /buildcache && \
mkdir -p /usr/local/etc && \
echo "store-dir=/buildcache/pnpm_store" >> /usr/local/etc/npmrc && \
mkdir -p /usr/src/app/upload && \
chown -R node:node /usr/src/app && \
chmod 755 /usr/src/app
COPY --chown=node:node --from=prod /buildcache /buildcache
COPY --chown=node:node --from=prod /usr/src/app/dist ./dist
COPY --chown=node:node --from=prod /usr/src/app/bin ./bin
COPY --chown=node:node --from=web /usr/src/app/build /build/www
COPY --chown=node:node --from=cli /usr/src/app/dist ./cli
COPY --chown=node:node server/resources ./resources/
COPY --chown=node:node server/package.json server/pnpm-lock.yaml pnpm-workspace.yaml server/start*.sh \
docker/scripts/get-cpus.sh ./
NVIDIA_VISIBLE_DEVICES=all
COPY --from=prod /usr/src/app/node_modules ./node_modules
COPY --from=prod /usr/src/app/dist ./dist
COPY --from=prod /usr/src/app/bin ./bin
COPY --from=web /usr/src/app/build /build/www
COPY server/resources resources
COPY server/package.json server/package-lock.json ./
COPY server/start*.sh ./
COPY "docker/scripts/get-cpus.sh" ./
RUN npm install -g @immich/cli && npm cache clean --force
COPY LICENSE /licenses/LICENSE.txt
COPY LICENSE /LICENSE
USER node
RUN pnpm install --frozen-lockfile --prod --no-optional && \
echo '#!/usr/bin/env node' > /usr/src/app/bin/immich && \
echo 'require("../cli/index.js");' >> /usr/src/app/bin/immich && \
chmod +x /usr/src/app/bin/immich
USER root
RUN rm -rf /buildcache /usr/local/etc/npmrc
USER node
ENV PATH="${PATH}:/usr/src/app/bin"
ARG BUILD_ID
@@ -217,8 +134,6 @@ ENV IMMICH_SOURCE_REF=${BUILD_SOURCE_REF}
ENV IMMICH_SOURCE_COMMIT=${BUILD_SOURCE_COMMIT}
ENV IMMICH_SOURCE_URL=https://github.com/immich-app/immich/commit/${BUILD_SOURCE_COMMIT}
USER root
VOLUME /usr/src/app/upload
EXPOSE 2283
ENTRYPOINT ["tini", "--", "/bin/bash"]

View File

@@ -1,3 +1,3 @@
#!/usr/bin/env bash
pnpm exec nest start --debug "0.0.0.0:9230" --watch -- "$@"
node /usr/src/app/node_modules/.bin/nest start --debug "0.0.0.0:9230" --watch -- "$@"

View File

@@ -32,7 +32,8 @@
"kysely:codegen": "npx kysely-codegen --include-pattern=\"(public|vectors).*\" --dialect postgres --url postgres://postgres:postgres@localhost/immich --log-level debug --out-file=./src/db.d.ts",
"sync:open-api": "node ./dist/bin/sync-open-api.js",
"sync:sql": "node ./dist/bin/sync-sql.js",
"email:dev": "email dev -p 3050 --dir src/emails"
"email:dev": "email dev -p 3050 --dir src/emails",
"postinstall": "patch-package"
},
"dependencies": {
"@nestjs/bullmq": "^11.0.1",
@@ -64,14 +65,14 @@
"bcrypt": "^6.0.0",
"body-parser": "^2.2.0",
"bullmq": "^5.51.0",
"chokidar": "^4.0.3",
"chokidar": "^3.5.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"compression": "^1.8.0",
"cookie": "^1.0.2",
"cookie-parser": "^1.4.7",
"cron": "^3.5.0",
"exiftool-vendored": "^28.8.0",
"exiftool-vendored": "^28.3.1",
"express": "^5.1.0",
"fast-glob": "^3.3.2",
"fluent-ffmpeg": "^2.1.2",
@@ -107,14 +108,13 @@
"sharp": "^0.34.2",
"sirv": "^3.0.0",
"socket.io": "^4.8.1",
"tailwindcss-preset-email": "^1.4.0",
"tailwindcss-preset-email": "^1.3.2",
"thumbhash": "^0.1.1",
"typeorm": "^0.3.17",
"ua-parser-js": "^2.0.0",
"validator": "^13.12.0"
},
"devDependencies": {
"canvas": "^3.1.0",
"@eslint/eslintrc": "^3.1.0",
"@eslint/js": "^9.8.0",
"@nestjs/cli": "^11.0.2",
@@ -156,6 +156,7 @@
"mock-fs": "^5.2.0",
"node-addon-api": "^8.3.1",
"node-gyp": "^11.2.0",
"patch-package": "^8.0.0",
"pngjs": "^7.0.0",
"prettier": "^3.0.2",
"prettier-plugin-organize-imports": "^4.0.0",
@@ -163,7 +164,6 @@
"source-map-support": "^0.5.21",
"sql-formatter": "^15.0.0",
"supertest": "^7.1.0",
"tailwindcss": "^3.4.0",
"testcontainers": "^11.0.0",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.3.3",
@@ -178,6 +178,5 @@
},
"overrides": {
"sharp": "^0.34.2"
},
"packageManager": "pnpm@10.12.3+sha512.467df2c586056165580ad6dfb54ceaad94c5a30f80893ebdec5a44c5aa73c205ae4a5bb9d5ed6bb84ea7c249ece786642bbb49d06a307df218d03da41c317417"
}
}

View File

@@ -1,7 +1,7 @@
diff --git a/cf/src/connection.js b/cf/src/connection.js
index ee8b1e69055bef090d322a66c7d792b5b502f47a..acf45662b35a8d01fa0d198faf6337d9f6808f3f 100644
--- a/cf/src/connection.js
+++ b/cf/src/connection.js
diff --git a/node_modules/postgres/cf/src/connection.js b/node_modules/postgres/cf/src/connection.js
index ee8b1e6..acf4566 100644
--- a/node_modules/postgres/cf/src/connection.js
+++ b/node_modules/postgres/cf/src/connection.js
@@ -387,8 +387,10 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose
}
@@ -14,10 +14,10 @@ index ee8b1e69055bef090d322a66c7d792b5b502f47a..acf45662b35a8d01fa0d198faf6337d9
query: { value: query.string, enumerable: options.debug },
parameters: { value: query.parameters, enumerable: options.debug },
args: { value: query.args, enumerable: options.debug },
diff --git a/cjs/src/connection.js b/cjs/src/connection.js
index f7f58d147f344cb7f2420c2bd24c6b7917162033..b7f2d657c9e1d5fbc7ce4735f61abae972959b1e 100644
--- a/cjs/src/connection.js
+++ b/cjs/src/connection.js
diff --git a/node_modules/postgres/cjs/src/connection.js b/node_modules/postgres/cjs/src/connection.js
index f7f58d1..b7f2d65 100644
--- a/node_modules/postgres/cjs/src/connection.js
+++ b/node_modules/postgres/cjs/src/connection.js
@@ -385,8 +385,10 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose
}
@@ -30,10 +30,10 @@ index f7f58d147f344cb7f2420c2bd24c6b7917162033..b7f2d657c9e1d5fbc7ce4735f61abae9
query: { value: query.string, enumerable: options.debug },
parameters: { value: query.parameters, enumerable: options.debug },
args: { value: query.args, enumerable: options.debug },
diff --git a/src/connection.js b/src/connection.js
index 97cc97e1576d6c75f958c66e9cecbf8cd11ed450..26f508e2de12f09f27838aca8d88fa4721fe6677 100644
--- a/src/connection.js
+++ b/src/connection.js
diff --git a/node_modules/postgres/src/connection.js b/node_modules/postgres/src/connection.js
index 97cc97e..26f508e 100644
--- a/node_modules/postgres/src/connection.js
+++ b/node_modules/postgres/src/connection.js
@@ -385,8 +385,10 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose
}

12106
server/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import archiver from 'archiver';
import chokidar, { ChokidarOptions } from 'chokidar';
import chokidar, { WatchOptions } from 'chokidar';
import { escapePath, glob, globStream } from 'fast-glob';
import { constants, createReadStream, createWriteStream, existsSync, mkdirSync } from 'node:fs';
import fs from 'node:fs/promises';
@@ -219,14 +219,14 @@ export class StorageRepository {
}
}
watch(paths: string[], options: ChokidarOptions, events: Partial<WatchEvents>) {
watch(paths: string[], options: WatchOptions, events: Partial<WatchEvents>) {
const watcher = chokidar.watch(paths, options);
watcher.on('ready', () => events.onReady?.());
watcher.on('add', (path) => events.onAdd?.(path));
watcher.on('change', (path) => events.onChange?.(path));
watcher.on('unlink', (path) => events.onUnlink?.(path));
watcher.on('error', (error) => events.onError?.(error as Error));
watcher.on('error', (error) => events.onError?.(error));
return () => watcher.close();
}

View File

@@ -1,4 +1,4 @@
import { ChokidarOptions } from 'chokidar';
import { WatchOptions } from 'chokidar';
import { StorageCore } from 'src/cores/storage.core';
import { StorageRepository, WatchEvents } from 'src/repositories/storage.repository';
import { RepositoryInterface } from 'src/types';
@@ -11,7 +11,7 @@ interface MockWatcherOptions {
export const makeMockWatcher =
({ items, close }: MockWatcherOptions) =>
(paths: string[], options: ChokidarOptions, events: Partial<WatchEvents>) => {
(paths: string[], options: WatchOptions, events: Partial<WatchEvents>) => {
events.onReady?.();
for (const item of items || []) {
switch (item.event) {
@@ -29,7 +29,6 @@ export const makeMockWatcher =
}
case 'error': {
events.onError?.(new Error(item.value));
break;
}
}
}

1
web/.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict=true

View File

@@ -1,33 +1,11 @@
FROM node:22.16.0-alpine3.20@sha256:2289fb1fba0f4633b08ec47b94a89c7e20b829fc5679f9b7b298eaa2f1ed8b7e
ENV COREPACK_ENABLE_AUTO_PIN=0 \
COREPACK_ENABLE_DOWNLOAD_PROMPT=0
RUN corepack enable && corepack install -g pnpm && \
apk add --no-cache tini && \
mkdir -p /pnpm/store && \
chown node:node -R /pnpm && \
mkdir -p /usr/local/etc && \
echo "store-dir=/pnpm/store" >> /usr/local/etc/npmrc
RUN apk add --no-cache tini
USER node
WORKDIR /usr/src/app
COPY --chown=node:node \
../open-api/typescript-sdk/package.json \
../open-api/typescript-sdk/pnpm-lock.yaml \
../open-api/typescript-sdk/
COPY --chown=node:node \
./web/package.json \
./web/pnpm-lock.yaml \
pnpm-workspace.yaml \
./
RUN pnpm install --frozen-lockfile
COPY --chown=node:node package*.json ./
RUN npm ci
ENV CHOKIDAR_USEPOLLING=true
EXPOSE 24678
EXPOSE 3000
ENTRYPOINT ["/sbin/tini", "--", "/bin/sh"]

View File

@@ -1,9 +1,10 @@
#!/usr/bin/env sh
echo "Building TypeScript SDK..."
(cd ../open-api/typescript-sdk && pnpm install && pnpm build)
echo "Installing Deps ..."
pnpm install
TYPESCRIPT_SDK=/usr/src/open-api/typescript-sdk
npm --prefix "$TYPESCRIPT_SDK" install
npm --prefix "$TYPESCRIPT_SDK" run build
COUNT=0
UPSTREAM="${IMMICH_SERVER_URL:-http://immich-server:2283/}"
@@ -17,4 +18,4 @@ done
echo "Connected to $UPSTREAM"
pnpm exec vite dev --host 0.0.0.0 --port 3000
node ./node_modules/.bin/vite dev --host 0.0.0.0 --port 3000

20
web/package-lock.json generated
View File

@@ -26,6 +26,7 @@
"intl-messageformat": "^10.7.11",
"justified-layout": "^4.1.0",
"lodash-es": "^4.17.21",
"lru-cache": "^11.1.0",
"luxon": "^3.4.4",
"maplibre-gl": "^5.3.0",
"pmtiles": "^4.3.0",
@@ -6592,11 +6593,13 @@
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
"license": "ISC"
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz",
"integrity": "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==",
"license": "ISC",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/lru-queue": {
"version": "0.1.0",
@@ -7335,6 +7338,13 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/path-scurry/node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
"license": "ISC"
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",

View File

@@ -5,24 +5,25 @@
"type": "module",
"scripts": {
"dev": "vite dev --host 0.0.0.0 --port 3000",
"build": "svelte-kit sync && vite build",
"build": "vite build",
"build:stats": "BUILD_STATS=true vite build",
"package": "svelte-kit package",
"preview": "vite preview",
"check:svelte": "svelte-check --no-tsconfig --fail-on-warnings --compiler-warnings 'reactive_declaration_non_reactive_property:ignore' --ignore src/lib/components/photos-page/asset-grid.svelte",
"check:typescript": "svelte-kit sync && tsc --noEmit",
"check:typescript": "tsc --noEmit",
"check:watch": "npm run check:svelte -- --watch",
"check:code": "npm run format && npm run lint:p && npm run check:svelte && npm run check:typescript",
"check:all": "npm run check:code && npm run test:cov",
"lint": "svelte-kit sync && eslint . --max-warnings 0",
"lint:p": "svelte-kit sync && eslint-p . --max-warnings 0 --concurrency=4",
"lint:fix": "svelte-kit sync && npm run lint -- --fix",
"lint": "eslint . --max-warnings 0",
"lint:p": "eslint-p . --max-warnings 0 --concurrency=4",
"lint:fix": "npm run lint -- --fix",
"format": "prettier --check .",
"format:fix": "prettier --write . && npm run format:i18n",
"format:i18n": "npx --yes sort-json ../i18n/*.json",
"test": "vitest --run",
"test:cov": "vitest --coverage",
"test:watch": "vitest dev"
"test:watch": "vitest dev",
"prepare": "svelte-kit sync"
},
"dependencies": {
"@formatjs/icu-messageformat-parser": "^2.9.8",
@@ -38,12 +39,11 @@
"@zoom-image/svelte": "^0.3.0",
"dom-to-image": "^2.6.0",
"fabric": "^6.5.4",
"geojson": "^0.5.0",
"handlebars": "^4.7.8",
"happy-dom": "^18.0.1",
"intl-messageformat": "^10.7.11",
"justified-layout": "^4.1.0",
"lodash-es": "^4.17.21",
"lru-cache": "^11.1.0",
"luxon": "^3.4.4",
"maplibre-gl": "^5.3.0",
"pmtiles": "^4.3.0",
@@ -104,6 +104,5 @@
},
"volta": {
"node": "22.16.0"
},
"packageManager": "pnpm@10.12.3+sha512.467df2c586056165580ad6dfb54ceaad94c5a30f80893ebdec5a44c5aa73c205ae4a5bb9d5ed6bb84ea7c249ece786642bbb49d06a307df218d03da41c317417"
}
}

6874
web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,25 +0,0 @@
import { photoZoomState } from '$lib/stores/zoom-image.store';
import { useZoomImageWheel } from '@zoom-image/svelte';
import { get } from 'svelte/store';
export const zoomImageAction = (node: HTMLElement) => {
const { createZoomImage, zoomImageState, setZoomImageState } = useZoomImageWheel();
createZoomImage(node, {
maxZoom: 10,
});
const state = get(photoZoomState);
if (state) {
setZoomImageState(state);
}
const unsubscribes = [photoZoomState.subscribe(setZoomImageState), zoomImageState.subscribe(photoZoomState.set)];
return {
destroy() {
for (const unsubscribe of unsubscribes) {
unsubscribe();
}
},
};
};

View File

@@ -1,7 +1,8 @@
<script lang="ts">
import type { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import MapModal from '$lib/modals/MapModal.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { navigate } from '$lib/utils/navigation';
import { getAlbumInfo, type AlbumResponseDto, type MapMarkerResponseDto } from '@immich/sdk';
import { IconButton } from '@immich/ui';
import { mdiMapOutline } from '@mdi/js';
@@ -9,12 +10,12 @@
import { t } from 'svelte-i18n';
interface Props {
assetManager: AssetManager;
album: AlbumResponseDto;
}
let { album }: Props = $props();
let { assetManager = $bindable(), album }: Props = $props();
let abortController: AbortController;
let { setAssetId } = assetViewingStore;
let mapMarkers: MapMarkerResponseDto[] = $state([]);
@@ -24,7 +25,7 @@
onDestroy(() => {
abortController?.abort();
assetViewingStore.showAssetViewer(false);
assetManager.showAssetViewer = false;
});
async function loadMapMarkers() {
@@ -56,7 +57,7 @@
const assetIds = await modalManager.show(MapModal, { mapMarkers });
if (assetIds) {
await setAssetId(assetIds[0]);
await navigate({ targetRoute: 'current', assetId: assetIds[0] });
}
}
</script>

View File

@@ -4,9 +4,9 @@
import AlbumMap from '$lib/components/album-page/album-map.svelte';
import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import type { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
import { featureFlags } from '$lib/stores/server-config.store';
import { handlePromiseError } from '$lib/utils';
@@ -25,16 +25,15 @@
import AlbumSummary from './album-summary.svelte';
interface Props {
assetManager: AssetManager;
sharedLink: SharedLinkResponseDto;
user?: UserResponseDto | undefined;
}
let { sharedLink, user = undefined }: Props = $props();
let { assetManager = $bindable(), sharedLink, user = undefined }: Props = $props();
const album = sharedLink.album as AlbumResponseDto;
let { isViewing: showAssetViewer } = assetViewingStore;
const timelineManager = new TimelineManager();
$effect(() => void timelineManager.updateOptions({ albumId: album.id, order: album.order }));
onDestroy(() => timelineManager.destroy());
@@ -53,7 +52,7 @@
use:shortcut={{
shortcut: { key: 'Escape' },
onShortcut: () => {
if (!$showAssetViewer && assetInteraction.selectionActive) {
if (!assetManager.showAssetViewer && assetInteraction.selectionActive) {
cancelMultiselect(assetInteraction);
}
},
@@ -61,7 +60,7 @@
/>
<main class="relative h-dvh overflow-hidden px-2 md:px-6 max-md:pt-(--navbar-height-md) pt-(--navbar-height)">
<AssetGrid enableRouting={true} {album} {timelineManager} {assetInteraction}>
<AssetGrid enableRouting={true} {album} {timelineManager} {assetInteraction} {assetManager}>
<section class="pt-8 md:pt-24 px-2 md:px-0">
<!-- ALBUM TITLE -->
<h1
@@ -129,7 +128,7 @@
/>
{/if}
{#if sharedLink.showMetadata && $featureFlags.loaded && $featureFlags.map}
<AlbumMap {album} />
<AlbumMap {assetManager} {album} />
{/if}
<ThemeButton />
{/snippet}

View File

@@ -1,22 +1,20 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import type { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { downloadFile } from '$lib/utils/asset-utils';
import { getAssetInfo } from '@immich/sdk';
import { IconButton } from '@immich/ui';
import { mdiFolderDownloadOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
asset: TimelineAsset;
assetManager: AssetManager;
menuItem?: boolean;
}
let { asset, menuItem = false }: Props = $props();
let { assetManager = $bindable(), menuItem = false }: Props = $props();
const onDownloadFile = async () => downloadFile(await getAssetInfo({ id: asset.id, key: authManager.key }));
const onDownloadFile = async () => downloadFile(assetManager.asset);
</script>
<svelte:document use:shortcut={{ shortcut: { key: 'd', shift: true }, onShortcut: onDownloadFile }} />

View File

@@ -21,8 +21,8 @@
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { AppRoute } from '$lib/constants';
import type { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { user } from '$lib/stores/user.store';
import { photoZoomState } from '$lib/stores/zoom-image.store';
import { getAssetJobName, getSharedLink } from '$lib/utils';
import { canCopyImageToClipboard } from '$lib/utils/asset-utils';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
@@ -32,7 +32,6 @@
AssetTypeEnum,
AssetVisibility,
type AlbumResponseDto,
type AssetResponseDto,
type PersonResponseDto,
type StackResponseDto,
} from '@immich/sdk';
@@ -55,7 +54,7 @@
import { t } from 'svelte-i18n';
interface Props {
asset: AssetResponseDto;
assetManager: AssetManager;
album?: AlbumResponseDto | null;
person?: PersonResponseDto | null;
stack?: StackResponseDto | null;
@@ -75,7 +74,7 @@
}
let {
asset,
assetManager = $bindable(),
album = null,
person = null,
stack = null,
@@ -93,6 +92,9 @@
motionPhoto,
}: Props = $props();
let asset = $derived(assetManager.asset!);
let zoomImageState = $derived(assetManager.zoomImageState);
const sharedLink = getSharedLink();
let isOwner = $derived($user && asset.ownerId === $user?.id);
let showDownloadButton = $derived(sharedLink ? sharedLink.allowDownload : !asset.isOffline);
@@ -141,7 +143,7 @@
color="secondary"
variant="ghost"
shape="round"
icon={$photoZoomState && $photoZoomState.currentZoom > 1 ? mdiMagnifyMinusOutline : mdiMagnifyPlusOutline}
icon={zoomImageState && zoomImageState.currentZoom > 1 ? mdiMagnifyMinusOutline : mdiMagnifyPlusOutline}
aria-label={$t('zoom_image')}
onclick={onZoomImage}
/>
@@ -158,7 +160,7 @@
{/if}
{#if !isOwner && showDownloadButton}
<DownloadAction asset={toTimelineAsset(asset)} />
<DownloadAction {assetManager} />
{/if}
{#if showDetailButton}
@@ -177,7 +179,7 @@
<MenuOption icon={mdiPresentationPlay} text={$t('slideshow')} onClick={onPlaySlideshow} />
{/if}
{#if showDownloadButton}
<DownloadAction asset={toTimelineAsset(asset)} menuItem />
<DownloadAction {assetManager} menuItem />
{/if}
{#if !isLocked}

View File

@@ -7,22 +7,21 @@
import AssetViewerNavBar from '$lib/components/asset-viewer/asset-viewer-nav-bar.svelte';
import { AssetAction, ProjectionType } from '$lib/constants';
import { activityManager } from '$lib/managers/activity-manager.svelte';
import { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { closeEditorCofirm } from '$lib/stores/asset-editor.store';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isShowDetail } from '$lib/stores/preferences.store';
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { user } from '$lib/stores/user.store';
import { websocketEvents } from '$lib/stores/websocket';
import { getAssetJobMessage, getSharedLink, handlePromiseError } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { navigate } from '$lib/utils/navigation';
import { SlideshowHistory } from '$lib/utils/slideshow-history';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import {
AssetJobName,
AssetTypeEnum,
getAllAlbums,
getStack,
runAssetJobs,
type AlbumResponseDto,
@@ -48,8 +47,7 @@
type HasAsset = boolean;
interface Props {
asset: AssetResponseDto;
preloadAssets?: TimelineAsset[];
assetManager: AssetManager;
showNavigation?: boolean;
withStacked?: boolean;
isShared?: boolean;
@@ -61,13 +59,12 @@
onClose: (asset: AssetResponseDto) => void;
onNext: () => Promise<HasAsset>;
onPrevious: () => Promise<HasAsset>;
onRandom: () => Promise<{ id: string } | undefined>;
onRandom: () => Promise<HasAsset>;
copyImage?: () => Promise<void>;
}
let {
asset = $bindable(),
preloadAssets = $bindable([]),
assetManager = $bindable(),
showNavigation = true,
withStacked = false,
isShared = false,
@@ -83,7 +80,6 @@
copyImage = $bindable(),
}: Props = $props();
const { setAssetId } = assetViewingStore;
const {
restartProgress: restartSlideshowProgress,
stopProgress: stopSlideshowProgress,
@@ -94,10 +90,13 @@
const stackThumbnailSize = 60;
const stackSelectedThumbnailSize = 65;
let appearsInAlbums: AlbumResponseDto[] = $state([]);
let asset = $derived(assetManager.asset);
let preloadAssets = $derived(assetManager.preloadAssets);
let albums = $derived(assetManager.albums);
let shouldPlayMotionPhoto = $state(false);
let sharedLink = getSharedLink();
let enableDetailPanel = asset.hasMetadata;
let enableDetailPanel = $derived(asset?.hasMetadata ?? false);
let slideshowStateUnsubscribe: () => void;
let shuffleSlideshowUnsubscribe: () => void;
let previewStackedAsset: AssetResponseDto | undefined = $state();
@@ -106,7 +105,6 @@
let fullscreenElement = $state<Element>();
let unsubscribes: (() => void)[] = [];
let selectedEditType: string = $state('');
let stack: StackResponseDto | null = $state(null);
let zoomToggle = $state(() => void 0);
@@ -146,7 +144,7 @@
}
};
onMount(async () => {
onMount(() => {
unsubscribes.push(
websocketEvents.on('on_upload_success', (asset) => onAssetUpdate({ event: 'upload', asset })),
websocketEvents.on('on_asset_update', (asset) => onAssetUpdate({ event: 'update', asset })),
@@ -169,9 +167,7 @@
}
});
if (!sharedLink) {
await handleGetAllAlbums();
}
// TODO: empty shared link returns 404.
});
onDestroy(() => {
@@ -190,18 +186,6 @@
activityManager.reset();
});
const handleGetAllAlbums = async () => {
if (authManager.key) {
return;
}
try {
appearsInAlbums = await getAllAlbums({ assetId: asset.id });
} catch (error) {
console.error('Error getting album that asset belong to', error);
}
};
const handleOpenActivity = () => {
if ($isShowDetail) {
$isShowDetail = false;
@@ -238,11 +222,11 @@
let hasNext = false;
if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) {
hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
hasNext = order === 'previous' ? await slideshowHistory.previous() : await slideshowHistory.next();
if (!hasNext) {
const asset = await onRandom();
if (asset) {
slideshowHistory.queue(asset);
await onRandom();
if (assetManager.asset) {
slideshowHistory.queue(assetManager.asset);
hasNext = true;
}
}
@@ -281,8 +265,9 @@
let assetViewerHtmlElement = $state<HTMLElement>();
const slideshowHistory = new SlideshowHistory((asset) => {
handlePromiseError(setAssetId(asset.id).then(() => ($restartSlideshowProgress = true)));
const slideshowHistory = new SlideshowHistory(async (asset) => {
await navigate({ targetRoute: 'current', assetId: asset.id });
$restartSlideshowProgress = true;
});
const handleVideoStarted = () => {
@@ -325,7 +310,7 @@
const handleAction = async (action: Action) => {
switch (action.type) {
case AssetAction.ADD_TO_ALBUM: {
await handleGetAllAlbums();
await assetManager.refreshAlbums();
break;
}
case AssetAction.SET_STACK_PRIMARY_ASSET: {
@@ -364,11 +349,6 @@
handlePromiseError(activityManager.init(album.id, asset.id));
}
});
$effect(() => {
if (asset.id) {
handlePromiseError(handleGetAllAlbums());
}
});
</script>
<svelte:document bind:fullscreenElement />
@@ -383,7 +363,7 @@
{#if $slideshowState === SlideshowState.None && !isShowEditor}
<div class="col-span-4 col-start-1 row-span-1 row-start-1 transition-transform">
<AssetViewerNavBar
{asset}
{assetManager}
{album}
{person}
{stack}
@@ -461,8 +441,7 @@
{#if asset.type === AssetTypeEnum.Image}
{#if shouldPlayMotionPhoto && asset.livePhotoVideoId}
<VideoViewer
assetId={asset.livePhotoVideoId}
cacheKey={asset.thumbhash}
{assetManager}
projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
onPreviousAsset={() => navigateAsset('previous')}
@@ -472,15 +451,14 @@
{:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || (asset.originalPath && asset.originalPath
.toLowerCase()
.endsWith('.insp'))}
<ImagePanoramaViewer {asset} />
<ImagePanoramaViewer {assetManager} />
{:else if isShowEditor && selectedEditType === 'crop'}
<CropArea {asset} />
{:else}
<PhotoViewer
bind:zoomToggle
bind:copyImage
{asset}
{preloadAssets}
{assetManager}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
{sharedLink}
@@ -489,8 +467,7 @@
{/if}
{:else}
<VideoViewer
assetId={asset.id}
cacheKey={asset.thumbhash}
{assetManager}
projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
onPreviousAsset={() => navigateAsset('previous')}
@@ -529,7 +506,7 @@
class="row-start-1 row-span-4 w-[360px] overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray bg-light"
translate="yes"
>
<DetailPanel {asset} currentAlbum={album} albums={appearsInAlbums} onClose={() => ($isShowDetail = false)} />
<DetailPanel {asset} currentAlbum={album} {albums} onClose={() => ($isShowDetail = false)} />
</div>
{/if}

View File

@@ -13,7 +13,7 @@
import { locale } from '$lib/stores/preferences.store';
import { featureFlags } from '$lib/stores/server-config.store';
import { preferences, user } from '$lib/stores/user.store';
import { getAssetThumbnailUrl, getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils';
import { delay, isFlipped } from '$lib/utils/asset-utils';
import { getByteUnitString } from '$lib/utils/byte-units';
import { handleError } from '$lib/utils/handle-error';
@@ -22,7 +22,6 @@
import { getParentPath } from '$lib/utils/tree-utils';
import {
AssetMediaSize,
getAssetInfo,
updateAsset,
type AlbumResponseDto,
type AssetResponseDto,
@@ -83,19 +82,6 @@
let isOwner = $derived($user?.id === asset.ownerId);
const handleNewAsset = async (newAsset: AssetResponseDto) => {
// TODO: check if reloading asset data is necessary
if (newAsset.id && !authManager.key) {
const data = await getAssetInfo({ id: asset.id });
people = data?.people || [];
unassignedFaces = data?.unassignedFaces || [];
}
};
$effect(() => {
handlePromiseError(handleNewAsset(asset));
});
let latlng = $derived(
(() => {
const lat = asset.exifInfo?.latitude;
@@ -127,11 +113,8 @@
return undefined;
};
const handleRefreshPeople = async () => {
await getAssetInfo({ id: asset.id }).then((data) => {
people = data?.people || [];
unassignedFaces = data?.unassignedFaces || [];
});
// TODO: refresh people
const handleRefreshPeople = () => {
showEditFaces = false;
};

View File

@@ -5,6 +5,7 @@
import { onDestroy, onMount, tick } from 'svelte';
import { t } from 'svelte-i18n';
import type { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import {
changedOriention,
cropAspectRatio,
@@ -13,7 +14,6 @@
rotateDegrees,
} from '$lib/stores/asset-editor.store';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import type { AssetResponseDto } from '@immich/sdk';
import { animateCropChange, recalculateCrop } from './crop-settings';
import { cropAreaEl, cropFrame, imgElement, isResizingOrDragging, overlayEl, resetCropStore } from './crop-store';
import { draw } from './drawing';
@@ -21,10 +21,10 @@
import { handleMouseDown, handleMouseMove, handleMouseUp } from './mouse-handlers';
interface Props {
asset: AssetResponseDto;
assetManager: AssetManager;
}
let { asset }: Props = $props();
let { assetManager = $bindable() }: Props = $props();
let img = $state<HTMLImageElement>();

View File

@@ -2,7 +2,6 @@
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
import { notificationController } from '$lib/components/shared-components/notification/notification';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { getPeopleThumbnailUrl } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
@@ -303,7 +302,8 @@
},
});
await assetViewingStore.setAssetId(assetId);
// TODO: manual tag face
// await assetViewingStore.setAssetId(assetId);
} catch (error) {
handleError(error, 'Error tagging face');
} finally {

View File

@@ -1,17 +1,19 @@
<script lang="ts">
import type { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { getAssetOriginalUrl } from '$lib/utils';
import { isWebCompatibleImage } from '$lib/utils/asset-utils';
import { AssetMediaSize, viewAsset, type AssetResponseDto } from '@immich/sdk';
import { AssetMediaSize, viewAsset } from '@immich/sdk';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
interface Props {
asset: AssetResponseDto;
assetManager: AssetManager;
}
const { asset }: Props = $props();
// TODO: do not preload assets.
const { assetManager = $bindable() }: Props = $props();
const loadAssetData = async (id: string) => {
const data = await viewAsset({ id, size: AssetMediaSize.Preview, key: authManager.key });

View File

@@ -1,26 +1,28 @@
<script lang="ts">
import { shortcuts } from '$lib/actions/shortcut';
import { zoomImageAction } from '$lib/actions/zoom-image';
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import { assetViewerFadeDuration } from '$lib/constants';
import { type AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import {
cancelImageLoad,
mediaLoadError,
mediaLoaded,
} from '$lib/managers/asset-manager/internal/load-support.svelte';
import { zoomImageAttachment } from '$lib/managers/asset-manager/internal/zoom-support.svelte';
import { castManager } from '$lib/managers/cast-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { boundingBoxesArray } from '$lib/stores/people.store';
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
import { photoZoomState } from '$lib/stores/zoom-image.store';
import { getAssetOriginalUrl, getAssetThumbnailUrl, handlePromiseError } from '$lib/utils';
import { canCopyImageToClipboard, copyImageToClipboard, isWebCompatibleImage } from '$lib/utils/asset-utils';
import { handlePromiseError } from '$lib/utils';
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { getBoundingBox } from '$lib/utils/people-utils';
import { cancelImageUrl } from '$lib/utils/sw-messaging';
import { getAltText } from '$lib/utils/thumbnail-util';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetMediaSize, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
import { onDestroy, onMount } from 'svelte';
import { type SharedLinkResponseDto } from '@immich/sdk';
import { onDestroy } from 'svelte';
import { swipe, type SwipeCustomEvent } from 'svelte-gestures';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
@@ -28,8 +30,7 @@
import { NotificationType, notificationController } from '../shared-components/notification/notification';
interface Props {
asset: AssetResponseDto;
preloadAssets?: TimelineAsset[] | undefined;
assetManager: AssetManager;
element?: HTMLDivElement | undefined;
haveFadeTransition?: boolean;
sharedLink?: SharedLinkResponseDto | undefined;
@@ -40,8 +41,7 @@
}
let {
asset,
preloadAssets = undefined,
assetManager = $bindable(),
element = $bindable(),
haveFadeTransition = true,
sharedLink = undefined,
@@ -53,51 +53,25 @@
const { slideshowState, slideshowLook } = slideshowStore;
let assetFileUrl: string = $state('');
let imageLoaded: boolean = $state(false);
let originalImageLoaded: boolean = $state(false);
let imageError: boolean = $state(false);
let zoomImageState = $derived(assetManager.zoomImageState);
let loader = $state<HTMLImageElement>();
photoZoomState.set({
currentRotation: 0,
currentZoom: 1,
enable: true,
currentPositionX: 0,
currentPositionY: 0,
});
zoomToggle = () => {
if (zoomImageState) {
zoomImageState.currentZoom = zoomImageState.currentZoom > 1 ? 1 : 2;
}
};
onDestroy(() => {
$boundingBoxesArray = [];
});
const preload = (targetSize: AssetMediaSize | 'original', preloadAssets?: TimelineAsset[]) => {
for (const preloadAsset of preloadAssets || []) {
if (preloadAsset.isImage) {
let img = new Image();
img.src = getAssetUrl(preloadAsset.id, targetSize, preloadAsset.thumbhash);
}
}
};
const getAssetUrl = (id: string, targetSize: AssetMediaSize | 'original', cacheKey: string | null) => {
if (sharedLink && (!sharedLink.allowDownload || !sharedLink.showMetadata)) {
return getAssetThumbnailUrl({ id, size: AssetMediaSize.Preview, cacheKey });
}
return targetSize === 'original'
? getAssetOriginalUrl({ id, cacheKey })
: getAssetThumbnailUrl({ id, size: targetSize, cacheKey });
};
copyImage = async () => {
if (!canCopyImageToClipboard()) {
return;
}
try {
await copyImageToClipboard($photoViewerImgElement ?? assetFileUrl);
await copyImageToClipboard($photoViewerImgElement ?? assetManager.url!);
notificationController.show({
type: NotificationType.Info,
message: $t('copied_image_to_clipboard'),
@@ -108,17 +82,10 @@
}
};
zoomToggle = () => {
photoZoomState.set({
...$photoZoomState,
currentZoom: $photoZoomState.currentZoom > 1 ? 1 : 2,
});
};
const onPlaySlideshow = () => ($slideshowState = SlideshowState.PlaySlideshow);
$effect(() => {
if (isFaceEditMode.value && $photoZoomState.currentZoom > 1) {
if (isFaceEditMode.value && zoomImageState && zoomImageState.currentZoom > 1) {
zoomToggle();
}
});
@@ -132,7 +99,7 @@
};
const onSwipe = (event: SwipeCustomEvent) => {
if ($photoZoomState.currentZoom > 1) {
if (!zoomImageState || zoomImageState.currentZoom > 1) {
return;
}
if (onNextAsset && event.detail.direction === 'left') {
@@ -143,21 +110,10 @@
}
};
// when true, will force loading of the original image
let forceUseOriginal: boolean = $derived(asset.originalMimeType === 'image/gif' || $photoZoomState.currentZoom > 1);
const targetImageSize = $derived.by(() => {
if ($alwaysLoadOriginalFile || forceUseOriginal || originalImageLoaded) {
return isWebCompatibleImage(asset) ? 'original' : AssetMediaSize.Fullsize;
}
return AssetMediaSize.Preview;
});
$effect(() => {
if (assetFileUrl) {
if (assetManager.url) {
// this can't be in an async context with $effect
void cast(assetFileUrl);
void cast(assetManager.url);
}
});
@@ -175,35 +131,10 @@
}
};
const onload = () => {
imageLoaded = true;
assetFileUrl = imageLoaderUrl;
originalImageLoaded = targetImageSize === AssetMediaSize.Fullsize || targetImageSize === 'original';
};
const onerror = () => {
imageError = imageLoaded = true;
};
$effect(() => {
preload(targetImageSize, preloadAssets);
onDestroy(() => {
cancelImageLoad(assetManager);
});
onMount(() => {
if (loader?.complete) {
onload();
}
loader?.addEventListener('load', onload, { passive: true });
loader?.addEventListener('error', onerror, { passive: true });
return () => {
loader?.removeEventListener('load', onload);
loader?.removeEventListener('error', onerror);
cancelImageUrl(imageLoaderUrl);
};
});
let imageLoaderUrl = $derived(getAssetUrl(asset.id, targetImageSize, asset.thumbhash));
let containerWidth = $state(0);
let containerHeight = $state(0);
</script>
@@ -217,27 +148,32 @@
{ shortcut: { key: 'z' }, onShortcut: zoomToggle, preventDefault: false },
]}
/>
{#if imageError}
{#if assetManager.loadError}
<div class="h-full w-full">
<BrokenAsset class="text-xl h-full w-full" />
</div>
{/if}
<!-- svelte-ignore a11y_missing_attribute -->
<img bind:this={loader} style="display:none" src={imageLoaderUrl} aria-hidden="true" />
<div
bind:this={element}
class="relative h-full select-none"
bind:clientWidth={containerWidth}
bind:clientHeight={containerHeight}
>
<img style="display:none" src={imageLoaderUrl} alt="" {onload} {onerror} />
{#if !imageLoaded}
<img
style="display:none"
src={assetManager.url}
alt=""
aria-hidden="true"
onload={() => mediaLoaded(assetManager)}
onerror={() => mediaLoadError(assetManager)}
/>
{#if !assetManager.isLoaded}
<div id="spinner" class="flex h-full items-center justify-center">
<LoadingSpinner />
</div>
{:else if !imageError}
{:else if !assetManager.loadError}
<div
use:zoomImageAction
{@attach zoomImageAttachment(assetManager)}
use:swipe={() => ({})}
onswipe={onSwipe}
class="h-full w-full"
@@ -245,7 +181,7 @@
>
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
<img
src={assetFileUrl}
src={assetManager.url}
alt=""
class="-z-1 absolute top-0 start-0 object-cover h-full w-full blur-lg"
draggable="false"
@@ -253,15 +189,15 @@
{/if}
<img
bind:this={$photoViewerImgElement}
src={assetFileUrl}
alt={$getAltText(toTimelineAsset(asset))}
src={assetManager.url}
alt={$getAltText(toTimelineAsset(assetManager.asset!))}
class="h-full w-full {$slideshowState === SlideshowState.None
? 'object-contain'
: slideshowLookCssMapping[$slideshowLook]}"
draggable="false"
/>
<!-- eslint-disable-next-line svelte/require-each-key -->
{#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewerImgElement) as boundingbox}
{#each getBoundingBox($boundingBoxesArray, zoomImageState!, $photoViewerImgElement) as boundingbox}
<div
class="absolute border-solid border-white border-[3px] rounded-lg"
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
@@ -270,7 +206,12 @@
</div>
{#if isFaceEditMode.value}
<FaceEditor htmlElement={$photoViewerImgElement} {containerWidth} {containerHeight} assetId={asset.id} />
<FaceEditor
htmlElement={$photoViewerImgElement}
{containerWidth}
{containerHeight}
assetId={assetManager.asset!.id}
/>
{/if}
{/if}
</div>

View File

@@ -3,6 +3,7 @@
import VideoRemoteViewer from '$lib/components/asset-viewer/video-remote-viewer.svelte';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { assetViewerFadeDuration } from '$lib/constants';
import { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { castManager } from '$lib/managers/cast-manager.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { loopVideo as loopVideoPreference, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
@@ -16,9 +17,8 @@
import { fade } from 'svelte/transition';
interface Props {
assetId: string;
assetManager: AssetManager;
loopVideo: boolean;
cacheKey: string | null;
onPreviousAsset?: () => void;
onNextAsset?: () => void;
onVideoEnded?: () => void;
@@ -27,9 +27,8 @@
}
let {
assetId,
assetManager = $bindable(),
loopVideo,
cacheKey,
onPreviousAsset = () => {},
onNextAsset = () => {},
onVideoEnded = () => {},

View File

@@ -3,12 +3,13 @@
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import type { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
interface Props {
assetId: string;
assetManager: AssetManager;
}
const { assetId }: Props = $props();
const { assetManager = $bindable() }: Props = $props();
const modules = Promise.all([
import('./photo-sphere-viewer-adapter.svelte').then((module) => module.default),

View File

@@ -1,12 +1,12 @@
<script lang="ts">
import { ProjectionType } from '$lib/constants';
import VideoNativeViewer from '$lib/components/asset-viewer/video-native-viewer.svelte';
import VideoPanoramaViewer from '$lib/components/asset-viewer/video-panorama-viewer.svelte';
import { ProjectionType } from '$lib/constants';
import type { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
interface Props {
assetId: string;
assetManager: AssetManager;
projectionType: string | null | undefined;
cacheKey: string | null;
loopVideo: boolean;
onClose?: () => void;
onPreviousAsset?: () => void;
@@ -15,10 +15,10 @@
onVideoStarted?: () => void;
}
// TODO: do not preload assets.
let {
assetId,
assetManager = $bindable(),
projectionType,
cacheKey,
loopVideo,
onPreviousAsset,
onClose,
@@ -29,12 +29,11 @@
</script>
{#if projectionType === ProjectionType.EQUIRECTANGULAR}
<VideoPanoramaViewer {assetId} />
<VideoPanoramaViewer {assetManager} />
{:else}
<VideoNativeViewer
{loopVideo}
{cacheKey}
{assetId}
{assetManager}
{onPreviousAsset}
{onNextAsset}
{onVideoEnded}

View File

@@ -3,7 +3,6 @@
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { timeBeforeShowLoadingSpinner } from '$lib/constants';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
import { boundingBoxesArray } from '$lib/stores/people.store';
import { websocketEvents } from '$lib/stores/websocket';
@@ -20,6 +19,7 @@
type AssetFaceResponseDto,
type PersonResponseDto,
} from '@immich/sdk';
import { IconButton } from '@immich/ui';
import { mdiAccountOff, mdiArrowLeftThin, mdiPencil, mdiRestart, mdiTrashCan } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
@@ -28,7 +28,6 @@
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
import { NotificationType, notificationController } from '../shared-components/notification/notification';
import AssignFaceSidePanel from './assign-face-side-panel.svelte';
import { IconButton } from '@immich/ui';
interface Props {
assetId: string;
@@ -184,7 +183,8 @@
peopleWithFaces = peopleWithFaces.filter((f) => f.id !== face.id);
await assetViewingStore.setAssetId(assetId);
// TODO: manual tag face
// await assetViewingStore.setAssetId(assetId);
} catch (error) {
handleError(error, $t('error_delete_face'));
}

View File

@@ -26,10 +26,10 @@
NotificationType,
} from '$lib/components/shared-components/notification/notification';
import { AppRoute, QueryParameter } from '$lib/constants';
import { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { type MemoryAsset, memoryStore } from '$lib/stores/memory.store.svelte';
import { locale, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
import { preferences } from '$lib/stores/user.store';
@@ -61,6 +61,12 @@
import { t } from 'svelte-i18n';
import { Tween } from 'svelte/motion';
interface Props {
assetManager: AssetManager;
}
let { assetManager = $bindable() }: Props = $props();
let memoryGallery: HTMLElement | undefined = $state();
let memoryWrapper: HTMLElement | undefined = $state();
let galleryInView = $state(false);
@@ -76,7 +82,6 @@
let isSaved = $derived(current?.memory.isSaved);
let viewerHeight = $state(0);
const { isViewing } = assetViewingStore;
const viewport: Viewport = $state({ width: 0, height: 0 });
// need to include padding in the viewport for gallery
const galleryViewport: Viewport = $derived({ height: viewport.height, width: viewport.width - 32 });
@@ -85,7 +90,7 @@
let videoPlayer: HTMLVideoElement | undefined = $state();
const asHref = (asset: { id: string }) => `?${QueryParameter.ID}=${asset.id}`;
const handleNavigate = async (asset?: { id: string }) => {
if ($isViewing) {
if (assetManager.showAssetViewer) {
return asset;
}
@@ -251,7 +256,7 @@
if (playerInitialized || isVideoAssetButPlayerHasNotLoadedYet) {
return;
}
if ($isViewing) {
if (assetManager.showAssetViewer) {
handlePromiseError(handleAction('initPlayer[AssetViewOpen]', 'pause'));
} else {
handlePromiseError(handleAction('initPlayer[AssetViewClosed]', 'reset'));
@@ -296,7 +301,7 @@
</script>
<svelte:document
use:shortcuts={$isViewing
use:shortcuts={assetManager.showAssetViewer
? []
: [
{ shortcut: { key: 'ArrowRight' }, onShortcut: () => handleNextAsset() },
@@ -640,6 +645,7 @@
bind:this={memoryGallery}
>
<GalleryViewer
{assetManager}
onNext={handleNextAsset}
onPrevious={handlePreviousAsset}
assets={currentTimelineAssets}

View File

@@ -12,7 +12,7 @@
import ChangeDate from '$lib/components/shared-components/change-date.svelte';
import Scrubber from '$lib/components/shared-components/scrubber/scrubber.svelte';
import { AppRoute, AssetAction } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
@@ -20,7 +20,6 @@
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { showDeleteModal } from '$lib/stores/preferences.store';
@@ -36,7 +35,7 @@
type ScrubberListener,
type TimelinePlainYearMonth,
} from '$lib/utils/timeline-util';
import { AssetVisibility, getAssetInfo, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
import { AssetVisibility, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
import { DateTime } from 'luxon';
import { onMount, type Snippet } from 'svelte';
import type { UpdatePayload } from 'vite';
@@ -53,6 +52,7 @@
enableRouting: boolean;
timelineManager: TimelineManager;
assetInteraction: AssetInteraction;
assetManager: AssetManager;
removeAction?:
| AssetAction.UNARCHIVE
| AssetAction.ARCHIVE
@@ -78,6 +78,7 @@
enableRouting,
timelineManager = $bindable(),
assetInteraction,
assetManager = $bindable(),
removeAction = null,
withStacked = false,
showArchiveIcon = false,
@@ -91,8 +92,6 @@
empty,
}: Props = $props();
let { isViewing: showAssetViewer, asset: viewingAsset, preloadAssets, gridScrollTarget } = assetViewingStore;
let element: HTMLElement | undefined = $state();
let timelineElement: HTMLElement | undefined = $state();
@@ -103,6 +102,8 @@
let scrubOverallPercent: number = $state(0);
let scrubberWidth = $state(0);
let asset = $derived(assetManager.asset);
// 60 is the bottom spacer element at 60px
let bottomSectionHeight = 60;
let leadout = $state(false);
@@ -177,7 +178,7 @@
};
const completeNav = async () => {
const scrollTarget = $gridScrollTarget?.at;
const scrollTarget = assetManager.gridScrollTarget?.at;
let scrolled = false;
if (scrollTarget) {
scrolled = await scrollToAssetId(scrollTarget);
@@ -212,9 +213,9 @@
setTimeout(() => {
const asset = $page.url.searchParams.get('at');
if (asset) {
$gridScrollTarget = { at: asset };
assetManager.gridScrollTarget = { at: asset };
void navigate(
{ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget },
{ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: assetManager.gridScrollTarget },
{ replaceState: true, forceNavigate: true },
);
} else {
@@ -438,46 +439,50 @@
};
const handlePrevious = async () => {
const laterAsset = await timelineManager.getLaterAsset($viewingAsset);
if (laterAsset) {
const preloadAsset = await timelineManager.getLaterAsset(laterAsset);
const asset = await getAssetInfo({ id: laterAsset.id, key: authManager.key });
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
await navigate({ targetRoute: 'current', assetId: laterAsset.id });
let laterAsset = undefined;
if (asset) {
laterAsset = await timelineManager.getLaterAsset(asset);
if (laterAsset) {
// TODO: If preloadAsset is undefined, throw an exception.
// assetManager.preloadAssets = [await timelineManager.getLaterAsset(laterAsset)];
await navigate({ targetRoute: 'current', assetId: laterAsset.id });
}
}
return !!laterAsset;
};
const handleNext = async () => {
const earlierAsset = await timelineManager.getEarlierAsset($viewingAsset);
if (earlierAsset) {
const preloadAsset = await timelineManager.getEarlierAsset(earlierAsset);
const asset = await getAssetInfo({ id: earlierAsset.id, key: authManager.key });
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
await navigate({ targetRoute: 'current', assetId: earlierAsset.id });
let earlierAsset = undefined;
if (asset) {
earlierAsset = await timelineManager.getEarlierAsset(asset);
if (earlierAsset) {
// assetManager.preloadAssets = [await timelineManager.getEarlierAsset(earlierAsset)];
await navigate({ targetRoute: 'current', assetId: earlierAsset.id });
}
}
return !!earlierAsset;
};
const handleRandom = async () => {
const randomAsset = await timelineManager.getRandomAsset();
if (randomAsset) {
const asset = await getAssetInfo({ id: randomAsset.id, key: authManager.key });
assetViewingStore.setAsset(asset);
await navigate({ targetRoute: 'current', assetId: randomAsset.id });
return asset;
let randomAsset = undefined;
if (asset) {
randomAsset = await timelineManager.getRandomAsset();
if (randomAsset) {
await navigate({ targetRoute: 'current', assetId: randomAsset.id });
}
}
return !!randomAsset;
};
const handleClose = async (asset: { id: string }) => {
assetViewingStore.showAssetViewer(false);
assetManager.showAssetViewer = false;
showSkeleton = true;
$gridScrollTarget = { at: asset.id };
await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget });
assetManager.gridScrollTarget = { at: asset.id };
await navigate({
targetRoute: 'current',
assetId: null,
assetGridRouteSearchParams: assetManager.gridScrollTarget,
});
};
const handlePreAction = async (action: Action) => {
@@ -722,7 +727,7 @@
let shortcutList = $derived(
(() => {
if (searchStore.isSearchEnabled || $showAssetViewer) {
if (searchStore.isSearchEnabled || assetManager.showAssetViewer) {
return [];
}
@@ -775,8 +780,8 @@
});
$effect(() => {
if ($showAssetViewer) {
const { localDateTime } = getTimes($viewingAsset.fileCreatedAt, DateTime.local().offset / 60);
if (assetManager.showAssetViewer && asset) {
const { localDateTime } = getTimes(asset.fileCreatedAt, DateTime.local().offset / 60);
void timelineManager.loadMonthGroup({ year: localDateTime.year, month: localDateTime.month });
}
});
@@ -923,12 +928,11 @@
</section>
<Portal target="body">
{#if $showAssetViewer}
{#if assetManager.showAssetViewer}
{#await import('../asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<AssetViewer
{assetManager}
{withStacked}
asset={$viewingAsset}
preloadAssets={$preloadAssets}
{isShared}
{album}
{person}

View File

@@ -3,16 +3,18 @@
import type { Action } from '$lib/components/asset-viewer/actions/action';
import ImmichLogoSmallLink from '$lib/components/shared-components/immich-logo-small-link.svelte';
import { AppRoute, AssetAction } from '$lib/constants';
import type { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import type { Viewport } from '$lib/managers/timeline-manager/types';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
import { handlePromiseError } from '$lib/utils';
import { cancelMultiselect, downloadArchive } from '$lib/utils/asset-utils';
import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader';
import { handleError } from '$lib/utils/handle-error';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { addSharedLinkAssets, getAssetInfo, type SharedLinkResponseDto } from '@immich/sdk';
import { addSharedLinkAssets, type SharedLinkResponseDto } from '@immich/sdk';
import { IconButton } from '@immich/ui';
import { mdiArrowLeft, mdiFileImagePlusOutline, mdiFolderDownloadOutline, mdiSelectAll } from '@mdi/js';
import { t } from 'svelte-i18n';
import AssetViewer from '../asset-viewer/asset-viewer.svelte';
@@ -22,14 +24,14 @@
import ControlAppBar from '../shared-components/control-app-bar.svelte';
import GalleryViewer from '../shared-components/gallery-viewer/gallery-viewer.svelte';
import { NotificationType, notificationController } from '../shared-components/notification/notification';
import { IconButton } from '@immich/ui';
interface Props {
assetManager: AssetManager;
sharedLink: SharedLinkResponseDto;
isOwned: boolean;
}
let { sharedLink = $bindable(), isOwned }: Props = $props();
let { assetManager = $bindable(), sharedLink = $bindable(), isOwned }: Props = $props();
const viewport: Viewport = $state({ width: 0, height: 0 });
const assetInteraction = new AssetInteraction();
@@ -86,6 +88,13 @@
}
}
};
$effect(() => {
// TODO: defer init until trigger updateOptions
if (assets.length === 1) {
void assetManager.updateOptions({ assetId: assets[0].id });
}
});
</script>
<section>
@@ -142,19 +151,17 @@
</ControlAppBar>
{/if}
<section class="my-[160px] mx-4" bind:clientHeight={viewport.height} bind:clientWidth={viewport.width}>
<GalleryViewer {assets} {assetInteraction} {viewport} />
<GalleryViewer {assets} {assetInteraction} {assetManager} {viewport} />
</section>
{:else if assets.length === 1}
{#await getAssetInfo({ id: assets[0].id, key: authManager.key }) then asset}
<AssetViewer
{asset}
showCloseButton={false}
onAction={handleAction}
onPrevious={() => Promise.resolve(false)}
onNext={() => Promise.resolve(false)}
onRandom={() => Promise.resolve(undefined)}
onClose={() => {}}
/>
{/await}
<AssetViewer
{assetManager}
showCloseButton={false}
onAction={handleAction}
onPrevious={() => Promise.resolve(false)}
onNext={() => Promise.resolve(false)}
onRandom={() => Promise.resolve(false)}
onClose={() => {}}
/>
{/if}
</section>

View File

@@ -4,11 +4,11 @@
import type { Action } from '$lib/components/asset-viewer/actions/action';
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import { AppRoute, AssetAction } from '$lib/constants';
import type { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { showDeleteModal } from '$lib/stores/preferences.store';
import { featureFlags } from '$lib/stores/server-config.store';
import { handlePromiseError } from '$lib/utils';
@@ -29,6 +29,7 @@
interface Props {
assets: (TimelineAsset | AssetResponseDto)[];
assetInteraction: AssetInteraction;
assetManager: AssetManager;
disableAssetSelect?: boolean;
showArchiveIcon?: boolean;
viewport: Viewport;
@@ -46,6 +47,7 @@
let {
assets = $bindable(),
assetInteraction,
assetManager = $bindable(),
disableAssetSelect = false,
showArchiveIcon = false,
viewport,
@@ -60,8 +62,6 @@
pageHeaderOffset = 0,
}: Props = $props();
let { isViewing: isViewerOpen, asset: viewingAsset, setAssetId } = assetViewingStore;
let geometry: CommonJustifiedLayout | undefined = $state();
$effect(() => {
@@ -151,8 +151,7 @@
});
const viewAssetHandler = async (asset: TimelineAsset) => {
currentViewAssetIndex = assets.findIndex((a) => a.id == asset.id);
await setAssetId(assets[currentViewAssetIndex].id);
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
await navigate({ targetRoute: 'current', assetId: assets[currentViewAssetIndex].id });
};
const selectAllAssets = () => {
@@ -292,7 +291,7 @@
const shortcutList = $derived(
(() => {
if ($isViewerOpen) {
if (assetManager.showAssetViewer) {
return [];
}
@@ -344,7 +343,7 @@
}
};
const handleRandom = async (): Promise<{ id: string } | undefined> => {
const handleRandom = async (): Promise<boolean> => {
try {
let asset: { id: string } | undefined;
if (onRandom) {
@@ -357,14 +356,14 @@
}
if (!asset) {
return;
return false;
}
await navigateToAsset(asset);
return asset;
return true;
} catch (error) {
handleError(error, $t('errors.cannot_navigate_next_asset'));
return;
return false;
}
};
@@ -395,9 +394,8 @@
};
const navigateToAsset = async (asset?: { id: string }) => {
if (asset && asset.id !== $viewingAsset.id) {
await setAssetId(asset.id);
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
if (asset && asset.id !== assetManager.asset.id) {
await navigate({ targetRoute: 'current', assetId: asset.id });
}
};
@@ -415,7 +413,7 @@
} else if (currentViewAssetIndex === assets.length) {
await handlePrevious();
} else {
await setAssetId(assets[currentViewAssetIndex].id);
await navigate({ targetRoute: 'current', assetId: assets[currentViewAssetIndex].id });
}
break;
}
@@ -512,16 +510,16 @@
{/if}
<!-- Overlay Asset Viewer -->
{#if $isViewerOpen}
{#if assetManager.showAssetViewer}
<Portal target="body">
<AssetViewer
asset={$viewingAsset}
{assetManager}
onAction={handleAction}
onPrevious={handlePrevious}
onNext={handleNext}
onRandom={handleRandom}
onClose={() => {
assetViewingStore.showAssetViewer(false);
assetManager.showAssetViewer = false;
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
}}
/>

View File

@@ -2,7 +2,7 @@
import { shortcuts } from '$lib/actions/shortcut';
import Portal from '$lib/components/shared-components/portal/portal.svelte';
import DuplicateAsset from '$lib/components/utilities-page/duplicates/duplicate-asset.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import type { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { handlePromiseError } from '$lib/utils';
import { suggestDuplicate } from '$lib/utils/duplicate-utils';
import { navigate } from '$lib/utils/navigation';
@@ -15,12 +15,12 @@
interface Props {
assets: AssetResponseDto[];
assetManager: AssetManager;
onResolve: (duplicateAssetIds: string[], trashIds: string[]) => void;
onStack: (assets: AssetResponseDto[]) => void;
}
let { assets, onResolve, onStack }: Props = $props();
const { isViewing: showAssetViewer, asset: viewingAsset, setAsset } = assetViewingStore;
let { assets, assetManager, onResolve, onStack }: Props = $props();
const getAssetIndex = (id: string) => assets.findIndex((asset) => asset.id === id);
// eslint-disable-next-line svelte/no-unnecessary-state-wrap
@@ -39,35 +39,34 @@
});
onDestroy(() => {
assetViewingStore.showAssetViewer(false);
assetManager.showAssetViewer = false;
});
const onNext = () => {
const index = getAssetIndex($viewingAsset.id) + 1;
const onNext = async () => {
const index = getAssetIndex(assetManager.asset.id) + 1;
if (index >= assets.length) {
return Promise.resolve(false);
return false;
}
setAsset(assets[index]);
return Promise.resolve(true);
await navigate({ targetRoute: 'current', assetId: assets[index].id });
return true;
};
const onPrevious = () => {
const index = getAssetIndex($viewingAsset.id) - 1;
const onPrevious = async () => {
const index = getAssetIndex(assetManager.asset.id) - 1;
if (index < 0) {
return Promise.resolve(false);
return false;
}
setAsset(assets[index]);
return Promise.resolve(true);
await navigate({ targetRoute: 'current', assetId: assets[index].id });
return true;
};
const onRandom = () => {
const onRandom = async () => {
if (assets.length <= 0) {
return Promise.resolve(undefined);
return false;
}
const index = Math.floor(Math.random() * assets.length);
const asset = assets[index];
setAsset(asset);
return Promise.resolve(asset);
await navigate({ targetRoute: 'current', assetId: assets[index].id });
return true;
};
const onSelectAsset = (asset: AssetResponseDto) => {
@@ -102,9 +101,7 @@
{ shortcut: { key: 'a' }, onShortcut: onSelectAll },
{
shortcut: { key: 's' },
onShortcut: () => {
setAsset(assets[0]);
},
onShortcut: () => navigate({ targetRoute: 'current', assetId: assets[0].id }),
},
{ shortcut: { key: 'd' }, onShortcut: onSelectNone },
{ shortcut: { key: 'c', shift: true }, onShortcut: handleResolve },
@@ -170,23 +167,23 @@
{asset}
{onSelectAsset}
isSelected={selectedAssetIds.has(asset.id)}
onViewAsset={(asset) => setAsset(asset)}
onViewAsset={(asset) => navigate({ targetRoute: 'current', assetId: asset.id })}
/>
{/each}
</div>
</div>
{#if $showAssetViewer}
{#if assetManager.showAssetViewer}
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<Portal target="body">
<AssetViewer
asset={$viewingAsset}
{assetManager}
showNavigation={assets.length > 1}
{onNext}
{onPrevious}
{onRandom}
onClose={() => {
assetViewingStore.showAssetViewer(false);
assetManager.showAssetViewer = false;
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
}}
/>

View File

@@ -0,0 +1,202 @@
import type { AssetPackage } from '$lib/managers/asset-manager/asset-package.svelte';
import { loadFromAssetPackage } from '$lib/managers/asset-manager/internal/load-support.svelte';
import { CancellableTask } from '$lib/utils/cancellable-task';
import type { AssetGridRouteSearchParams } from '$lib/utils/navigation';
import { type ZoomImageWheelState } from '@zoom-image/core';
import { isEqual } from 'lodash-es';
import { LRUCache } from 'lru-cache';
export enum AssetMediaSize {
Original = 'original',
Fullsize = 'fullsize',
Preview = 'preview',
Thumbnail = 'thumbnail',
Playback = 'playback',
}
export type LoadAssetOptions = {
loadAlbums?: boolean;
loadStack?: boolean;
};
export type AssetManagerOptions = {};
export class AssetManager {
isInitialized = $state(false);
isLoaded = $state(false);
loadError = $state(false);
// The queue waited for load. The first is the currect and the next is preload.
// The preload asset is not need to loading immediately.
assetLoadingQueue: AssetPackage[] = $state([]);
// url: string | undefined = $derived.by(() => {
// if (this.asset) {
// return this.#getAssetUrl(toTimelineAsset(this.asset!));
// }
// });
#maximumLRUCache: number = $state(10);
// TODO: This function is used to test.
dispose(value: AssetPackage, key: string) {
console.log(key);
console.log(value);
}
assetCache: LRUCache<string, AssetPackage> = $state(
new LRUCache({ max: this.#maximumLRUCache, dispose: this.dispose }),
);
showAssetViewer: boolean = $state(false);
gridScrollTarget: AssetGridRouteSearchParams | undefined = $state();
zoomImageState: ZoomImageWheelState | undefined = $state();
initTask = new CancellableTask(
() => (this.isInitialized = true),
() => {
this.assetLoadingQueue = [];
this.assetCache.clear();
this.isInitialized = false;
},
() => void 0,
);
static #INIT_OPTIONS = {};
#options: AssetManagerOptions = AssetManager.#INIT_OPTIONS;
static #DEFAULT_LOAD_ASSET_OPTIONS: LoadAssetOptions = {
loadAlbums: false,
loadStack: false,
};
constructor() {}
async loadAssetPackage(options?: LoadAssetOptions, cancelable?: boolean): Promise<void> {
cancelable = cancelable ?? true;
options = options ?? AssetManager.#DEFAULT_LOAD_ASSET_OPTIONS;
const assetPackage = this.assetLoadingQueue[0];
if (!assetPackage) {
return;
}
if (assetPackage.loader?.executed) {
return;
}
const result = await assetPackage.loader?.execute(async (signal: AbortSignal) => {
await loadFromAssetPackage(this, assetPackage, options, signal);
}, cancelable);
}
async #initializeAsset() {
// TODO: Preload assets.
}
async updateOptions(options: AssetManagerOptions) {
if (this.#options !== AssetManager.#INIT_OPTIONS && isEqual(this.#options, options)) {
return;
}
await this.initTask.reset();
await this.#init(options);
}
async #init(options: AssetManagerOptions) {
this.isInitialized = false;
this.assetLoadingQueue = [];
this.assetCache.clear();
await this.initTask.execute(async () => {
this.#options = options;
await this.#initializeAsset();
}, true);
}
public destroy() {
this.isInitialized = false;
}
// #checkOptions() {
// this.#options.size = AssetMediaSize.Original;
// if (!this.asset || !this.zoomImageState) {
// return;
// }
// if (this.asset.originalMimeType === 'image/gif' || this.zoomImageState.currentZoom > 1) {
// // TODO: use original image forcely and according to the setting.
// }
// }
// #preload() {
// for (const preloadAsset of this.preloadAssets) {
// if (preloadAsset.isImage) {
// let img = new Image();
// const preloadUrl = this.#getAssetUrl(preloadAsset);
// if (preloadUrl) {
// img.src = preloadUrl;
// } else {
// throw new Error('AssetManager is not initialized.');
// }
// }
// }
// }
// #getAssetUrl(asset: TimelineAsset) {
// if (!this.asset) {
// return;
// }
// let path = undefined;
// const searchParameters = new URLSearchParams();
// if (authManager.key) {
// searchParameters.set('key', authManager.key);
// }
// if (this.cacheKey) {
// searchParameters.set('c', this.cacheKey);
// }
// switch (this.#options.size) {
// case AssetMediaSize.Original: {
// path = getAssetOriginalPath(this.asset.id);
// break;
// }
// case AssetMediaSize.Fullsize:
// case AssetMediaSize.Thumbnail:
// case AssetMediaSize.Preview: {
// path = getAssetThumbnailPath(this.asset.id);
// break;
// }
// case AssetMediaSize.Playback: {
// path = getAssetPlaybackPath(this.asset.id);
// break;
// }
// default:
// // TODO: default AssetMediaSize
// }
// return getBaseUrl() + path + '?' + searchParameters.toString();
// }
// get isOriginalImage() {
// return this.#options.size === AssetMediaSize.Original || this.#options.size === AssetMediaSize.Fullsize;
// }
}
// const getAssetUrl = (id: string, targetSize: AssetMediaSize | 'original', cacheKey: string | null) => {
// if (sharedLink && (!sharedLink.allowDownload || !sharedLink.showMetadata)) {
// return getAssetThumbnailUrl({ id, size: AssetMediaSize.Preview, cacheKey });
// }
// return targetSize === 'original'
// ? getAssetOriginalUrl({ id, cacheKey })
// : getAssetThumbnailUrl({ id, size: targetSize, cacheKey });
// };
// $effect(() => {
// if ($alwaysLoadOriginalFile || forceUseOriginal || originalImageLoaded) {
// assetManager.updateOptions({
// size: isWebCompatibleImage(asset) ? AssetMediaSize.Original : AssetMediaSize.Fullsize,
// });
// }
// assetManager.updateOptions({ size: AssetMediaSize.Preview });
// });

View File

@@ -0,0 +1,48 @@
import { CancellableTask } from '$lib/utils/cancellable-task';
import { handleError } from '$lib/utils/handle-error';
import type { AlbumResponseDto, AssetResponseDto, StackResponseDto } from '@immich/sdk';
import { t } from 'svelte-i18n';
import { get } from 'svelte/store';
import type { AssetManager, AssetManagerOptions } from './asset-manager.svelte';
export class AssetPackage {
isLoaded: boolean = $state(false);
asset: AssetResponseDto | undefined = $state();
albums: AlbumResponseDto[] = $state([]);
stack: StackResponseDto | undefined = $state();
readonly assetId: string;
readonly assetManager: AssetManager;
// To ensure albums and stack is need to reloading.
options: AssetManagerOptions | undefined = $state();
loader: CancellableTask | undefined;
constructor(store: AssetManager, assetId: string) {
this.assetManager = store;
this.assetId = assetId;
this.loader = new CancellableTask(
() => {
this.isLoaded = true;
},
() => {
this.asset = undefined;
this.albums = [];
this.stack = undefined;
this.isLoaded = false;
},
this.#handleLoadError,
);
}
// TODO: Add error message to translation.
#handleLoadError(error: unknown) {
const _$t = get(t);
handleError(error, _$t('errors.failed_to_load_asset'));
}
cancel() {
this.loader?.cancel();
}
}

View File

@@ -0,0 +1,81 @@
import type { AssetManager, LoadAssetOptions } from '$lib/managers/asset-manager/asset-manager.svelte';
import { AssetPackage } from '$lib/managers/asset-manager/asset-package.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
// import { cancelImageUrl } from '$lib/utils/sw-messaging';
import { getAllAlbums, getAssetInfo, getStack } from '@immich/sdk';
export async function loadFromAssetPackage(
assetManager: AssetManager,
assetPackage: AssetPackage,
options: LoadAssetOptions,
signal: AbortSignal,
): Promise<void> {
const assetId = assetPackage.assetId;
const assetCache = assetManager.assetCache.get(assetId);
if (assetCache && assetCache.options === options) {
return;
}
// TODO: Compare between assetCache and assetCache.options to ensure whether we need update or not.
// If there is assetCache, then asset info is not need to update.
if (!assetCache) {
const key = authManager.key;
const assetResponse = await getAssetInfo(
{
id: assetId,
key,
},
{ signal },
);
if (!assetResponse) {
throw new Error('get AssetInfo error');
}
assetPackage.asset = assetResponse;
}
// TODO: need to update albums
if (options.loadAlbums) {
const albumsResponse = await getAllAlbums(
{
assetId,
},
{ signal },
);
if (!albumsResponse) {
throw new Error('get AllAlbums error');
}
assetPackage.albums = albumsResponse;
}
if (options.loadStack) {
const stackResponse = await getStack(
{
id: assetId,
},
{ signal },
);
if (!stackResponse) {
throw new Error('get Stack error');
}
assetPackage.stack = stackResponse;
}
}
export function mediaLoaded(assetManager: AssetManager) {
assetManager.isLoaded = true;
}
export function mediaLoadError(assetManager: AssetManager) {
assetManager.isLoaded = assetManager.loadError = true;
}
// export function cancelImageLoad(assetManager: AssetManager) {
// if (assetManager.url) {
// cancelImageUrl(assetManager.url);
// }
// assetManager.isLoaded = assetManager.loadError = false;
// }

View File

@@ -0,0 +1,24 @@
import type { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { useZoomImageWheel } from '@zoom-image/svelte';
import type { Attachment } from 'svelte/attachments';
export function zoomImageAttachment(assetManager: AssetManager): Attachment<HTMLElement> {
return (element) => {
let zoomImage = $derived(assetManager.zoomImageState);
const { createZoomImage, zoomImageState, setZoomImageState } = useZoomImageWheel();
createZoomImage(element, { maxZoom: 10 });
$effect(() => {
if (zoomImage) {
setZoomImageState(zoomImage);
}
});
const unsubscribe = zoomImageState.subscribe((value) => (zoomImage = value));
return () => {
unsubscribe();
};
};
}

View File

@@ -0,0 +1,3 @@
import type { AssetPackage } from '$lib/managers/asset-manager/asset-package.svelte';
export const assetPackage = (assetPackage: AssetPackage): AssetPackage => $state.snapshot(assetPackage);

View File

@@ -1,40 +0,0 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { type AssetGridRouteSearchParams } from '$lib/utils/navigation';
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
import { readonly, writable } from 'svelte/store';
function createAssetViewingStore() {
const viewingAssetStoreState = writable<AssetResponseDto>();
const preloadAssets = writable<TimelineAsset[]>([]);
const viewState = writable<boolean>(false);
const gridScrollTarget = writable<AssetGridRouteSearchParams | null | undefined>();
const setAsset = (asset: AssetResponseDto, assetsToPreload: TimelineAsset[] = []) => {
preloadAssets.set(assetsToPreload);
viewingAssetStoreState.set(asset);
viewState.set(true);
};
const setAssetId = async (id: string): Promise<AssetResponseDto> => {
const asset = await getAssetInfo({ id, key: authManager.key });
setAsset(asset);
return asset;
};
const showAssetViewer = (show: boolean) => {
viewState.set(show);
};
return {
asset: readonly(viewingAssetStoreState),
preloadAssets: readonly(preloadAssets),
isViewing: viewState,
gridScrollTarget,
setAsset,
setAssetId,
showAssetViewer,
};
}
export const assetViewingStore = createAssetViewingStore();

View File

@@ -1,4 +0,0 @@
import type { ZoomImageWheelState } from '@zoom-image/core';
import { writable } from 'svelte/store';
export const photoZoomState = writable<ZoomImageWheelState>();

View File

@@ -58,8 +58,7 @@ describe('get asset filename', () => {
});
describe('copy image to clipboard', () => {
// This test is dubious, as it totally on the environment where the test is run which is mocked.
it('should allow copy image to clipboard', () => {
expect(canCopyImageToClipboard()).toEqual(true);
it('should not allow copy image to clipboard', () => {
expect(canCopyImageToClipboard()).toEqual(false);
});
});

View File

@@ -1,7 +1,6 @@
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { AppRoute } from '$lib/constants';
import { getAssetInfo } from '@immich/sdk';
import type { NavigationTarget } from '@sveltejs/kit';
import { get } from 'svelte/store';
@@ -22,10 +21,6 @@ export const isLockedFolderRoute = (route?: string | null) => !!route?.startsWit
export const isAssetViewerRoute = (target?: NavigationTarget | null) =>
!!(target?.route.id?.endsWith('/[[assetId=id]]') && 'assetId' in (target?.params || {}));
export function getAssetInfoFromParam({ assetId, key }: { assetId?: string; key?: string }) {
return assetId ? getAssetInfo({ id: assetId, key }) : undefined;
}
function currentUrlWithoutAsset() {
const $page = get(page);
// This contains special casing for the /photos/:assetId route, which hangs directly

View File

@@ -2,7 +2,7 @@ export class SlideshowHistory {
private history: { id: string }[] = [];
private index = 0;
constructor(private onChange: (asset: { id: string }) => void) {}
constructor(private onChange: (asset: { id: string }) => Promise<void>) {}
reset() {
this.history = [];
@@ -18,23 +18,23 @@ export class SlideshowHistory {
}
}
next(): boolean {
async next(): Promise<boolean> {
if (this.index === this.history.length - 1) {
return false;
}
this.index++;
this.onChange(this.history[this.index]);
await this.onChange(this.history[this.index]);
return true;
}
previous(): boolean {
async previous(): Promise<boolean> {
if (this.index === 0) {
return false;
}
this.index--;
this.onChange(this.history[this.index]);
await this.onChange(this.history[this.index]);
return true;
}
}

View File

@@ -1,32 +1,23 @@
<script lang="ts">
import { run } from 'svelte/legacy';
import UploadCover from '$lib/components/shared-components/drag-and-drop-upload-overlay.svelte';
import { page } from '$app/stores';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import type { Snippet } from 'svelte';
interface Props {
children?: Snippet;
}
let { children }: Props = $props();
let { isViewing: showAssetViewer, setAsset, gridScrollTarget } = assetViewingStore;
// $page.data.asset is loaded by route specific +page.ts loaders if that
// page.data.asset is loaded by route specific +page.ts loaders if that
// route contains the assetId path.
run(() => {
if ($page.data.asset) {
setAsset($page.data.asset);
} else {
$showAssetViewer = false;
}
const asset = $page.url.searchParams.get('at');
$gridScrollTarget = { at: asset };
});
// $effect(() => {
// TODO: navigation to the asset grid.
// const asset = page.url.searchParams.get('at');
// gridScrollTarget = { at: asset };
// });
</script>
<div class:display-none={$showAssetViewer}>
<!-- display-none is based on assetManager.showAssetViewer -->
<div class:display-none={false}>
{@render children?.()}
</div>
<UploadCover />

View File

@@ -34,6 +34,7 @@
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
import { AlbumPageViewMode, AppRoute } from '$lib/constants';
import { activityManager } from '$lib/managers/activity-manager.svelte';
import { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
@@ -43,7 +44,6 @@
import QrCodeModal from '$lib/modals/QrCodeModal.svelte';
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { featureFlags } from '$lib/stores/server-config.store';
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { preferences, user } from '$lib/stores/user.store';
@@ -95,7 +95,6 @@
let { data = $bindable() }: Props = $props();
let { isViewing: showAssetViewer, setAssetId, gridScrollTarget } = assetViewingStore;
let { slideshowState, slideshowNavigation } = slideshowStore;
let oldAt: AssetGridRouteSearchParams | null | undefined = $state();
@@ -109,6 +108,15 @@
const assetInteraction = new AssetInteraction();
const timelineInteraction = new AssetInteraction();
const assetManager = new AssetManager();
$effect(() => {
if (data.assetId) {
assetManager.showAssetViewer = true;
void assetManager.updateOptions({ assetId: data.assetId });
}
});
onDestroy(() => assetManager.destroy());
afterNavigate(({ from }) => {
let url: string | undefined = from?.url?.pathname;
@@ -148,7 +156,8 @@
? await timelineManager.getRandomAsset()
: timelineManager.months[0]?.dayGroups[0]?.viewerAssets[0]?.asset;
if (asset) {
handlePromiseError(setAssetId(asset.id).then(() => ($slideshowState = SlideshowState.PlaySlideshow)));
await navigate({ targetRoute: 'current', assetId: asset.id });
$slideshowState = SlideshowState.PlaySlideshow;
}
};
@@ -166,7 +175,7 @@
viewMode = AlbumPageViewMode.VIEW;
return;
}
if ($showAssetViewer) {
if (assetManager.showAssetViewer) {
return;
}
if (assetInteraction.selectionActive) {
@@ -346,7 +355,7 @@
const isShared = $derived(viewMode === AlbumPageViewMode.SELECT_ASSETS ? false : album.albumUsers.length > 0);
$effect(() => {
if ($showAssetViewer || !isShared) {
if (assetManager.showAssetViewer || !isShared) {
return;
}
@@ -361,7 +370,9 @@
let isOwned = $derived($user.id == album.ownerId);
let showActivityStatus = $derived(
album.albumUsers.length > 0 && !$showAssetViewer && (album.isActivityEnabled || activityManager.commentCount > 0),
album.albumUsers.length > 0 &&
!assetManager.showAssetViewer &&
(album.isActivityEnabled || activityManager.commentCount > 0),
);
let isEditor = $derived(
album.albumUsers.find(({ user: { id } }) => id === $user.id)?.role === AlbumUserRole.Editor ||
@@ -449,6 +460,7 @@
{album}
{timelineManager}
assetInteraction={currentAssetIntersection}
{assetManager}
{isShared}
{isSelectionMode}
{singleSelect}
@@ -626,7 +638,7 @@
onclick={async () => {
timelineManager.suspendTransitions = true;
viewMode = AlbumPageViewMode.SELECT_ASSETS;
oldAt = { at: $gridScrollTarget?.at };
oldAt = { at: assetManager.gridScrollTarget?.at };
await navigate(
{ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: { at: null } },
{ replaceState: true },
@@ -648,7 +660,7 @@
{/if}
{#if $featureFlags.loaded && $featureFlags.map}
<AlbumMap {album} />
<AlbumMap {assetManager} {album} />
{/if}
{#if album.assetCount > 0}
@@ -735,7 +747,7 @@
{/if}
{/if}
</div>
{#if album.albumUsers.length > 0 && album && isShowActivity && $user && !$showAssetViewer}
{#if album.albumUsers.length > 0 && album && isShowActivity && $user && !assetManager.showAssetViewer}
<div class="flex">
<div
transition:fly={{ duration: 150 }}

View File

@@ -1,18 +1,14 @@
import { authenticate } from '$lib/utils/auth';
import { getAssetInfoFromParam } from '$lib/utils/navigation';
import { getAlbumInfo } from '@immich/sdk';
import type { PageLoad } from './$types';
export const load = (async ({ params, url }) => {
await authenticate(url);
const [album, asset] = await Promise.all([
getAlbumInfo({ id: params.albumId, withoutAssets: true }),
getAssetInfoFromParam(params),
]);
const album = await getAlbumInfo({ id: params.albumId, withoutAssets: true });
return {
album,
asset,
assetId: params.assetId,
meta: {
title: album.albumName,
},

View File

@@ -14,6 +14,7 @@
import { AssetAction } from '$lib/constants';
import SetVisibilityAction from '$lib/components/photos-page/actions/set-visibility-action.svelte';
import { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { AssetVisibility } from '@immich/sdk';
@@ -33,6 +34,15 @@
const assetInteraction = new AssetInteraction();
const assetManager = new AssetManager();
$effect(() => {
if (data.assetId) {
assetManager.showAssetViewer = true;
void assetManager.updateOptions({ assetId: data.assetId });
}
});
onDestroy(() => assetManager.destroy());
const handleEscape = () => {
if (assetInteraction.selectionActive) {
assetInteraction.clearMultiselect();
@@ -51,6 +61,7 @@
enableRouting={true}
{timelineManager}
{assetInteraction}
{assetManager}
removeAction={AssetAction.UNARCHIVE}
onEscape={handleEscape}
>

View File

@@ -1,15 +1,13 @@
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getAssetInfoFromParam } from '$lib/utils/navigation';
import type { PageLoad } from './$types';
export const load = (async ({ params, url }) => {
await authenticate(url);
const asset = await getAssetInfoFromParam(params);
const $t = await getFormatter();
return {
asset,
assetId: params.assetId,
meta: {
title: $t('archive'),
},

View File

@@ -17,6 +17,7 @@
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import { AssetAction } from '$lib/constants';
import { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { preferences } from '$lib/stores/user.store';
@@ -37,6 +38,15 @@
const assetInteraction = new AssetInteraction();
const assetManager = new AssetManager();
$effect(() => {
if (data.assetId) {
assetManager.showAssetViewer = true;
void assetManager.updateOptions({ assetId: data.assetId });
}
});
onDestroy(() => assetManager.destroy());
const handleEscape = () => {
if (assetInteraction.selectionActive) {
assetInteraction.clearMultiselect();
@@ -56,6 +66,7 @@
withStacked={true}
{timelineManager}
{assetInteraction}
{assetManager}
removeAction={AssetAction.UNFAVORITE}
onEscape={handleEscape}
>

View File

@@ -1,15 +1,13 @@
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getAssetInfoFromParam } from '$lib/utils/navigation';
import type { PageLoad } from './$types';
export const load = (async ({ params, url }) => {
await authenticate(url);
const asset = await getAssetInfoFromParam(params);
const $t = await getFormatter();
return {
asset,
assetId: params.assetId,
meta: {
title: $t('favorites'),
},

View File

@@ -21,8 +21,8 @@
import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte';
import Sidebar from '$lib/components/sidebar/sidebar.svelte';
import { AppRoute, QueryParameter } from '$lib/constants';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import type { Viewport } from '$lib/managers/timeline-manager/types';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { foldersStore } from '$lib/stores/folders.svelte';
import { preferences } from '$lib/stores/user.store';
import { cancelMultiselect } from '$lib/utils/asset-utils';
@@ -32,6 +32,8 @@
import { mdiDotsVertical, mdiFolder, mdiFolderHome, mdiFolderOutline, mdiPlus, mdiSelectAll } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
import { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { onDestroy } from 'svelte';
interface Props {
data: PageData;
@@ -43,6 +45,15 @@
const assetInteraction = new AssetInteraction();
const assetManager = new AssetManager();
$effect(() => {
if (data.assetId) {
assetManager.showAssetViewer = true;
void assetManager.updateOptions({ assetId: data.assetId });
}
});
onDestroy(() => assetManager.destroy());
const handleNavigateToFolder = (folderName: string) => navigateToView(joinPaths(data.tree.path, folderName));
function getLinkForPath(path: string) {
@@ -106,6 +117,7 @@
<GalleryViewer
assets={data.pathAssets}
{assetInteraction}
{assetManager}
{viewport}
showAssetName={true}
pageHeaderOffset={54}

View File

@@ -2,12 +2,11 @@ import { QueryParameter } from '$lib/constants';
import { foldersStore } from '$lib/stores/folders.svelte';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getAssetInfoFromParam } from '$lib/utils/navigation';
import type { PageLoad } from './$types';
export const load = (async ({ params, url }) => {
await authenticate(url);
const [, asset, $t] = await Promise.all([foldersStore.fetchTree(), getAssetInfoFromParam(params), getFormatter()]);
const [, $t] = await Promise.all([foldersStore.fetchTree(), getFormatter()]);
let tree = foldersStore.folders!;
const path = url.searchParams.get(QueryParameter.PATH);
@@ -23,7 +22,7 @@ export const load = (async ({ params, url }) => {
const pathAssets = tree.hasAssets ? await foldersStore.fetchAssetsByPath(tree.path) : null;
return {
asset,
assetId: params.assetId,
tree,
pathAssets,
meta: {

View File

@@ -12,6 +12,7 @@
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import { AppRoute, AssetAction } from '$lib/constants';
import { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { AssetVisibility, lockAuthSession } from '@immich/sdk';
@@ -33,6 +34,15 @@
const assetInteraction = new AssetInteraction();
const assetManager = new AssetManager();
$effect(() => {
if (data.assetId) {
assetManager.showAssetViewer = true;
void assetManager.updateOptions({ assetId: data.assetId });
}
});
onDestroy(() => assetManager.destroy());
const handleEscape = () => {
if (assetInteraction.selectionActive) {
assetInteraction.clearMultiselect();
@@ -62,6 +72,7 @@
enableRouting={true}
{timelineManager}
{assetInteraction}
{assetManager}
onEscape={handleEscape}
removeAction={AssetAction.SET_VISIBILITY_TIMELINE}
>

View File

@@ -1,7 +1,6 @@
import { AppRoute } from '$lib/constants';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getAssetInfoFromParam } from '$lib/utils/navigation';
import { getAuthStatus } from '@immich/sdk';
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
@@ -14,11 +13,10 @@ export const load = (async ({ params, url }) => {
redirect(302, `${AppRoute.AUTH_PIN_PROMPT}?continue=${encodeURIComponent(url.pathname + url.search)}`);
}
const asset = await getAssetInfoFromParam(params);
const $t = await getFormatter();
return {
asset,
assetId: params.assetId,
meta: {
title: $t('locked_folder'),
},

View File

@@ -1,12 +1,10 @@
<script lang="ts">
import { run } from 'svelte/legacy';
import { goto } from '$app/navigation';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import Map from '$lib/components/shared-components/map/map.svelte';
import Portal from '$lib/components/shared-components/portal/portal.svelte';
import { AppRoute } from '$lib/constants';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { featureFlags } from '$lib/stores/server-config.store';
import { handlePromiseError } from '$lib/utils';
import { navigate } from '$lib/utils/navigation';
@@ -19,16 +17,23 @@
let { data }: Props = $props();
let { isViewing: showAssetViewer, asset: viewingAsset, setAssetId } = assetViewingStore;
let viewingAssets: string[] = $state([]);
let viewingAssetCursor = 0;
const assetManager = new AssetManager();
$effect(() => {
if (data.assetId) {
assetManager.showAssetViewer = true;
void assetManager.updateOptions({ assetId: data.assetId });
}
});
onDestroy(() => assetManager.destroy());
onDestroy(() => {
assetViewingStore.showAssetViewer(false);
assetManager.showAssetViewer = false;
});
run(() => {
$effect(() => {
if (!$featureFlags.map) {
handlePromiseError(goto(AppRoute.PHOTOS));
}
@@ -37,13 +42,12 @@
async function onViewAssets(assetIds: string[]) {
viewingAssets = assetIds;
viewingAssetCursor = 0;
await setAssetId(assetIds[0]);
await navigate({ targetRoute: 'current', assetId: assetIds[0] });
}
async function navigateNext() {
if (viewingAssetCursor < viewingAssets.length - 1) {
await setAssetId(viewingAssets[++viewingAssetCursor]);
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
await navigate({ targetRoute: 'current', assetId: viewingAssets[++viewingAssetCursor] });
return true;
}
return false;
@@ -51,21 +55,19 @@
async function navigatePrevious() {
if (viewingAssetCursor > 0) {
await setAssetId(viewingAssets[--viewingAssetCursor]);
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
await navigate({ targetRoute: 'current', assetId: viewingAssets[--viewingAssetCursor] });
return true;
}
return false;
}
async function navigateRandom() {
if (viewingAssets.length <= 0) {
return undefined;
if (viewingAssets.length > 0) {
const index = Math.floor(Math.random() * viewingAssets.length);
await navigate({ targetRoute: 'current', assetId: viewingAssets[index] });
return true;
}
const index = Math.floor(Math.random() * viewingAssets.length);
const asset = await setAssetId(viewingAssets[index]);
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
return asset;
return false;
}
</script>
@@ -76,16 +78,16 @@
</div>
</UserPageLayout>
<Portal target="body">
{#if $showAssetViewer}
{#if assetManager.showAssetViewer}
{#await import('../../../../../lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<AssetViewer
asset={$viewingAsset}
{assetManager}
showNavigation={viewingAssets.length > 1}
onNext={navigateNext}
onPrevious={navigatePrevious}
onRandom={navigateRandom}
onClose={() => {
assetViewingStore.showAssetViewer(false);
assetManager.showAssetViewer = false;
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
}}
isShared={false}

View File

@@ -1,15 +1,12 @@
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getAssetInfoFromParam } from '$lib/utils/navigation';
import type { PageLoad } from './$types';
export const load = (async ({ params, url }) => {
await authenticate(url);
const asset = await getAssetInfoFromParam(params);
const $t = await getFormatter();
return {
asset,
assetId: params.assetId,
meta: {
title: $t('map'),
},

View File

@@ -1,5 +1,23 @@
<script>
<script lang="ts">
import MemoryViewer from '$lib/components/memory-page/memory-viewer.svelte';
import { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { onDestroy } from 'svelte';
import type { PageData } from './$types';
interface Props {
data: PageData;
}
let { data }: Props = $props();
const assetManager = new AssetManager();
$effect(() => {
if (data.assetId) {
assetManager.showAssetViewer = true;
void assetManager.updateOptions({ assetId: data.assetId });
}
});
onDestroy(() => assetManager.destroy());
</script>
<MemoryViewer />
<MemoryViewer {assetManager} />

View File

@@ -1,16 +1,14 @@
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getAssetInfoFromParam } from '$lib/utils/navigation';
import type { PageLoad } from './$types';
export const load = (async ({ params, url }) => {
const user = await authenticate(url);
const asset = await getAssetInfoFromParam(params);
const $t = await getFormatter();
return {
user,
asset,
assetId: params.assetId,
meta: {
title: $t('memory'),
},

View File

@@ -8,6 +8,7 @@
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import { AppRoute } from '$lib/constants';
import { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { AssetVisibility } from '@immich/sdk';
@@ -34,6 +35,15 @@
onDestroy(() => timelineManager.destroy());
const assetInteraction = new AssetInteraction();
const assetManager = new AssetManager();
$effect(() => {
if (data.assetId) {
assetManager.showAssetViewer = true;
void assetManager.updateOptions({ assetId: data.assetId });
}
});
onDestroy(() => assetManager.destroy());
const handleEscape = () => {
if (assetInteraction.selectionActive) {
assetInteraction.clearMultiselect();
@@ -43,7 +53,7 @@
</script>
<main class="relative h-dvh overflow-hidden px-2 md:px-6 max-md:pt-(--navbar-height-md) pt-(--navbar-height)">
<AssetGrid enableRouting={true} {timelineManager} {assetInteraction} onEscape={handleEscape} />
<AssetGrid enableRouting={true} {timelineManager} {assetInteraction} {assetManager} onEscape={handleEscape} />
</main>
{#if assetInteraction.selectionActive}

View File

@@ -1,6 +1,5 @@
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getAssetInfoFromParam } from '$lib/utils/navigation';
import { getUser } from '@immich/sdk';
import type { PageLoad } from './$types';
@@ -8,11 +7,10 @@ export const load = (async ({ params, url }) => {
await authenticate(url);
const partner = await getUser({ id: params.userId });
const asset = await getAssetInfoFromParam(params);
const $t = await getFormatter();
return {
asset,
assetId: params.assetId,
partner,
meta: {
title: $t('partner'),

View File

@@ -31,13 +31,13 @@
notificationController,
} from '$lib/components/shared-components/notification/notification';
import { AppRoute, PersonPageViewMode, QueryParameter, SessionStorageKey } from '$lib/constants';
import { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import PersonEditBirthDateModal from '$lib/modals/PersonEditBirthDateModal.svelte';
import PersonMergeSuggestionModal from '$lib/modals/PersonMergeSuggestionModal.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { locale } from '$lib/stores/preferences.store';
import { preferences } from '$lib/stores/user.store';
import { websocketEvents } from '$lib/stores/websocket';
@@ -75,7 +75,6 @@
let { data }: Props = $props();
let numberOfAssets = $state(data.statistics.assets);
let { isViewing: showAssetViewer } = assetViewingStore;
const timelineManager = new TimelineManager();
$effect(() => void timelineManager.updateOptions({ visibility: AssetVisibility.Timeline, personId: data.person.id }));
@@ -83,6 +82,15 @@
const assetInteraction = new AssetInteraction();
const assetManager = new AssetManager();
$effect(() => {
if (data.assetId) {
assetManager.showAssetViewer = true;
void assetManager.updateOptions({ assetId: data.assetId });
}
});
onDestroy(() => assetManager.destroy());
let viewMode: PersonPageViewMode = $state(PersonPageViewMode.VIEW_ASSETS);
let isEditingName = $state(false);
let previousRoute: string = $state(AppRoute.EXPLORE);
@@ -123,7 +131,7 @@
});
const handleEscape = async () => {
if ($showAssetViewer) {
if (assetManager.showAssetViewer) {
return;
}
if (assetInteraction.selectionActive) {
@@ -388,6 +396,7 @@
{person}
{timelineManager}
{assetInteraction}
{assetManager}
isSelectionMode={viewMode === PersonPageViewMode.SELECT_PERSON}
singleSelect={viewMode === PersonPageViewMode.SELECT_PERSON}
onSelect={handleSelectFeaturePhoto}

View File

@@ -1,23 +1,21 @@
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getAssetInfoFromParam } from '$lib/utils/navigation';
import { getPerson, getPersonStatistics } from '@immich/sdk';
import type { PageLoad } from './$types';
export const load = (async ({ params, url }) => {
await authenticate(url);
const [person, statistics, asset] = await Promise.all([
const [person, statistics] = await Promise.all([
getPerson({ id: params.personId }),
getPersonStatistics({ id: params.personId }),
getAssetInfoFromParam(params),
]);
const $t = await getFormatter();
return {
person,
statistics,
asset,
assetId: params.assetId,
meta: {
title: person.name || $t('person'),
},

View File

@@ -22,9 +22,9 @@
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import { AssetAction } from '$lib/constants';
import { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { preferences, user } from '$lib/stores/user.store';
import {
@@ -39,12 +39,27 @@
import { mdiDotsVertical, mdiPlus } from '@mdi/js';
import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
interface Props {
data: PageData;
}
let { data }: Props = $props();
let { isViewing: showAssetViewer } = assetViewingStore;
const timelineManager = new TimelineManager();
void timelineManager.updateOptions({ visibility: AssetVisibility.Timeline, withStacked: true, withPartners: true });
onDestroy(() => timelineManager.destroy());
const assetManager = new AssetManager();
$effect(() => {
if (data.assetId) {
assetManager.showAssetViewer = true;
void assetManager.updateOptions({ assetId: data.assetId });
}
});
onDestroy(() => assetManager.destroy());
const assetInteraction = new AssetInteraction();
let selectedAssets = $derived(assetInteraction.selectedAssets);
@@ -59,7 +74,7 @@
return assetInteraction.isAllUserOwned && (isLivePhoto || isLivePhotoCandidate);
});
const handleEscape = () => {
if ($showAssetViewer) {
if (assetManager.showAssetViewer) {
return;
}
if (assetInteraction.selectionActive) {
@@ -93,6 +108,7 @@
enableRouting={true}
{timelineManager}
{assetInteraction}
{assetManager}
removeAction={AssetAction.ARCHIVE}
onEscape={handleEscape}
withStacked

View File

@@ -1,15 +1,13 @@
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getAssetInfoFromParam } from '$lib/utils/navigation';
import type { PageLoad } from './$types';
export const load = (async ({ params, url }) => {
await authenticate(url);
const asset = await getAssetInfoFromParam(params);
const $t = await getFormatter();
return {
asset,
assetId: params.assetId,
meta: {
title: $t('photos'),
},

View File

@@ -23,10 +23,10 @@
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte';
import { AppRoute, QueryParameter } from '$lib/constants';
import { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { lang, locale } from '$lib/stores/preferences.store';
import { featureFlags } from '$lib/stores/server-config.store';
import { preferences } from '$lib/stores/user.store';
@@ -47,11 +47,17 @@
} from '@immich/sdk';
import { IconButton } from '@immich/ui';
import { mdiArrowLeft, mdiDotsVertical, mdiImageOffOutline, mdiPlus, mdiSelectAll } from '@mdi/js';
import { tick } from 'svelte';
import { onDestroy, tick } from 'svelte';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
interface Props {
data: PageData;
}
let { data }: Props = $props();
const MAX_ASSET_COUNT = 5000;
let { isViewing: showAssetViewer } = assetViewingStore;
const viewport: Viewport = $state({ width: 0, height: 0 });
// The GalleryViewer pushes it's own history state, which causes weird
@@ -83,8 +89,17 @@
let timelineManager = new TimelineManager();
const assetManager = new AssetManager();
$effect(() => {
if (data.assetId) {
assetManager.showAssetViewer = true;
void assetManager.updateOptions({ assetId: data.assetId });
}
});
onDestroy(() => assetManager.destroy());
const onEscape = () => {
if ($showAssetViewer) {
if (assetManager.showAssetViewer) {
return;
}
@@ -379,6 +394,7 @@
<GalleryViewer
assets={searchResultAssets}
{assetInteraction}
{assetManager}
onIntersected={loadNextPage}
showArchiveIcon={true}
{viewport}

View File

@@ -1,15 +1,13 @@
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getAssetInfoFromParam } from '$lib/utils/navigation';
import type { PageLoad } from './$types';
export const load = (async ({ params, url }) => {
await authenticate(url);
const asset = await getAssetInfoFromParam(params);
const $t = await getFormatter();
return {
asset,
assetId: params.assetId,
meta: {
title: $t('search'),
},

View File

@@ -5,14 +5,14 @@
import ImmichLogoSmallLink from '$lib/components/shared-components/immich-logo-small-link.svelte';
import PasswordField from '$lib/components/shared-components/password-field.svelte';
import ThemeButton from '$lib/components/shared-components/theme-button.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { user } from '$lib/stores/user.store';
import { setSharedLink } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { navigate } from '$lib/utils/navigation';
import { getMySharedLink, SharedLinkType } from '@immich/sdk';
import { Button } from '@immich/ui';
import { tick } from 'svelte';
import { onDestroy, tick } from 'svelte';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
@@ -22,12 +22,20 @@
let { data }: Props = $props();
let { gridScrollTarget } = assetViewingStore;
let { sharedLink, passwordRequired, sharedLinkKey: key, meta } = $state(data);
let { title, description } = $state(meta);
let isOwned = $derived($user ? $user.id === sharedLink?.userId : false);
let password = $state('');
const assetManager = new AssetManager();
$effect(() => {
if (data.assetId) {
assetManager.showAssetViewer = true;
void assetManager.updateOptions({ assetId: data.assetId });
}
});
onDestroy(() => assetManager.destroy());
const handlePasswordSubmit = async () => {
try {
sharedLink = await getMySharedLink({ password, key });
@@ -39,7 +47,7 @@
$t('shared_photos_and_videos_count', { values: { assetCount: sharedLink.assets.length } });
await tick();
await navigate(
{ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget },
{ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: assetManager.gridScrollTarget },
{ forceNavigate: true, replaceState: true },
);
} catch (error) {
@@ -88,10 +96,10 @@
{/if}
{#if !passwordRequired && sharedLink?.type == SharedLinkType.Album}
<AlbumViewer {sharedLink} />
<AlbumViewer {assetManager} {sharedLink} />
{/if}
{#if !passwordRequired && sharedLink?.type == SharedLinkType.Individual}
<div class="immich-scrollbar">
<IndividualSharedViewer {sharedLink} {isOwned} />
<IndividualSharedViewer {assetManager} {sharedLink} {isOwned} />
</div>
{/if}

View File

@@ -1,7 +1,6 @@
import { getAssetThumbnailUrl, setSharedLink } from '$lib/utils';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getAssetInfoFromParam } from '$lib/utils/navigation';
import { getMySharedLink, isHttpError } from '@immich/sdk';
import type { PageLoad } from './$types';
@@ -12,7 +11,7 @@ export const load = (async ({ params, url }) => {
const $t = await getFormatter();
try {
const [sharedLink, asset] = await Promise.all([getMySharedLink({ key }), getAssetInfoFromParam(params)]);
const sharedLink = await getMySharedLink({ key });
setSharedLink(sharedLink);
const assetCount = sharedLink.assets.length;
const assetId = sharedLink.album?.albumThumbnailAssetId || sharedLink.assets[0]?.id;
@@ -21,7 +20,7 @@ export const load = (async ({ params, url }) => {
return {
sharedLink,
sharedLinkKey: key,
asset,
assetId: params.assetId,
meta: {
title: sharedLink.album ? sharedLink.album.albumName : $t('public_share'),
description: sharedLink.description || $t('shared_photos_and_videos_count', { values: { assetCount } }),

Some files were not shown because too many files have changed in this diff Show More