mirror of
https://github.com/immich-app/immich.git
synced 2025-07-09 03:04:16 -04:00
Merge remote-tracking branch 'origin/main' into keynav_timeline
This commit is contained in:
commit
ee202624f7
118
.github/actions/image-build/action.yml
vendored
Normal file
118
.github/actions/image-build/action.yml
vendored
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
name: 'Single arch image build'
|
||||||
|
description: 'Build single-arch image on platform appropriate runner'
|
||||||
|
inputs:
|
||||||
|
image:
|
||||||
|
description: 'Name of the image to build'
|
||||||
|
required: true
|
||||||
|
ghcr-token:
|
||||||
|
description: 'GitHub Container Registry token'
|
||||||
|
required: true
|
||||||
|
platform:
|
||||||
|
description: 'Platform to build for'
|
||||||
|
required: true
|
||||||
|
artifact-key-base:
|
||||||
|
description: 'Base key for artifact name'
|
||||||
|
required: true
|
||||||
|
context:
|
||||||
|
description: 'Path to build context'
|
||||||
|
required: true
|
||||||
|
dockerfile:
|
||||||
|
description: 'Path to Dockerfile'
|
||||||
|
required: true
|
||||||
|
build-args:
|
||||||
|
description: 'Docker build arguments'
|
||||||
|
required: false
|
||||||
|
runs:
|
||||||
|
using: 'composite'
|
||||||
|
steps:
|
||||||
|
- name: Prepare
|
||||||
|
id: prepare
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
PLATFORM: ${{ inputs.platform }}
|
||||||
|
run: |
|
||||||
|
echo "platform-pair=${PLATFORM//\//-}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
||||||
|
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
|
||||||
|
if: ${{ !github.event.pull_request.head.repo.fork }}
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ inputs.ghcr-token }}
|
||||||
|
|
||||||
|
- name: Generate cache key suffix
|
||||||
|
id: cache-key-suffix
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
REF: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
||||||
|
echo "cache-key-suffix=pr-${{ github.event.number }}" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
SUFFIX=$(echo "${REF}" | sed 's/[^a-zA-Z0-9]/-/g')
|
||||||
|
echo "suffix=${SUFFIX}" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Generate cache target
|
||||||
|
id: cache-target
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
BUILD_ARGS: ${{ inputs.build-args }}
|
||||||
|
IMAGE: ${{ inputs.image }}
|
||||||
|
SUFFIX: ${{ steps.cache-key-suffix.outputs.suffix }}
|
||||||
|
PLATFORM_PAIR: ${{ steps.prepare.outputs.platform-pair }}
|
||||||
|
run: |
|
||||||
|
HASH=$(sha256sum <<< "${BUILD_ARGS}" | cut -d' ' -f1)
|
||||||
|
CACHE_KEY="${PLATFORM_PAIR}-${HASH}"
|
||||||
|
echo "cache-key-base=${CACHE_KEY}" >> $GITHUB_OUTPUT
|
||||||
|
if [[ "${{ github.event.pull_request.head.repo.fork }}" == "true" ]]; then
|
||||||
|
# Essentially just ignore the cache output (forks can't write to registry cache)
|
||||||
|
echo "cache-to=type=local,dest=/tmp/discard,ignore-error=true" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "cache-to=type=registry,ref=${IMAGE}-build-cache:${CACHE_KEY}-${SUFFIX},mode=max,compression=zstd" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Generate docker image tags
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5
|
||||||
|
env:
|
||||||
|
DOCKER_METADATA_PR_HEAD_SHA: 'true'
|
||||||
|
|
||||||
|
- name: Build and push image
|
||||||
|
id: build
|
||||||
|
uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0
|
||||||
|
with:
|
||||||
|
context: ${{ inputs.context }}
|
||||||
|
file: ${{ inputs.dockerfile }}
|
||||||
|
platforms: ${{ inputs.platform }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-to: ${{ steps.cache-target.outputs.cache-to }}
|
||||||
|
cache-from: |
|
||||||
|
type=registry,ref=${{ inputs.image }}-build-cache:${{ steps.cache-target.outputs.cache-key-base }}-${{ steps.cache-key-suffix.outputs.suffix }}
|
||||||
|
type=registry,ref=${{ inputs.image }}-build-cache:${{ steps.cache-target.outputs.cache-key-base }}-main
|
||||||
|
outputs: type=image,"name=${{ inputs.image }}",push-by-digest=true,name-canonical=true,push=${{ !github.event.pull_request.head.repo.fork }}
|
||||||
|
build-args: |
|
||||||
|
BUILD_ID=${{ github.run_id }}
|
||||||
|
BUILD_IMAGE=${{ github.event_name == 'release' && github.ref_name || steps.meta.outputs.tags }}
|
||||||
|
BUILD_SOURCE_REF=${{ github.ref_name }}
|
||||||
|
BUILD_SOURCE_COMMIT=${{ github.sha }}
|
||||||
|
${{ inputs.build-args }}
|
||||||
|
|
||||||
|
- name: Export digest
|
||||||
|
shell: bash
|
||||||
|
run: | # zizmor: ignore[template-injection]
|
||||||
|
mkdir -p ${{ runner.temp }}/digests
|
||||||
|
digest="${{ steps.build.outputs.digest }}"
|
||||||
|
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||||
|
|
||||||
|
- name: Upload digest
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||||
|
with:
|
||||||
|
name: ${{ inputs.artifact-key-base }}-${{ steps.cache-target.outputs.cache-key-base }}
|
||||||
|
path: ${{ runner.temp }}/digests/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 1
|
451
.github/workflows/docker.yml
vendored
451
.github/workflows/docker.yml
vendored
@ -40,6 +40,8 @@ jobs:
|
|||||||
- 'machine-learning/**'
|
- 'machine-learning/**'
|
||||||
workflow:
|
workflow:
|
||||||
- '.github/workflows/docker.yml'
|
- '.github/workflows/docker.yml'
|
||||||
|
- '.github/workflows/multi-runner-build.yml'
|
||||||
|
- '.github/actions/image-build'
|
||||||
|
|
||||||
- name: Check if we should force jobs to run
|
- name: Check if we should force jobs to run
|
||||||
id: should_force
|
id: should_force
|
||||||
@ -103,429 +105,74 @@ jobs:
|
|||||||
docker buildx imagetools create -t "${REGISTRY_NAME}/${REPOSITORY}:${TAG_PR}" "${REGISTRY_NAME}/${REPOSITORY}:${TAG_OLD}"
|
docker buildx imagetools create -t "${REGISTRY_NAME}/${REPOSITORY}:${TAG_PR}" "${REGISTRY_NAME}/${REPOSITORY}:${TAG_OLD}"
|
||||||
docker buildx imagetools create -t "${REGISTRY_NAME}/${REPOSITORY}:${TAG_COMMIT}" "${REGISTRY_NAME}/${REPOSITORY}:${TAG_OLD}"
|
docker buildx imagetools create -t "${REGISTRY_NAME}/${REPOSITORY}:${TAG_COMMIT}" "${REGISTRY_NAME}/${REPOSITORY}:${TAG_OLD}"
|
||||||
|
|
||||||
build_and_push_ml:
|
machine-learning:
|
||||||
name: Build and Push ML
|
name: Build and Push ML
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
if: ${{ needs.pre-job.outputs.should_run_ml == 'true' }}
|
if: ${{ needs.pre-job.outputs.should_run_ml == 'true' }}
|
||||||
runs-on: ${{ matrix.runner }}
|
|
||||||
env:
|
|
||||||
image: immich-machine-learning
|
|
||||||
context: machine-learning
|
|
||||||
file: machine-learning/Dockerfile
|
|
||||||
GHCR_REPO: ghcr.io/${{ github.repository_owner }}/immich-machine-learning
|
|
||||||
strategy:
|
strategy:
|
||||||
# Prevent a failure in one image from stopping the other builds
|
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- platform: linux/amd64
|
|
||||||
runner: ubuntu-latest
|
|
||||||
device: cpu
|
|
||||||
|
|
||||||
- platform: linux/arm64
|
|
||||||
runner: ubuntu-24.04-arm
|
|
||||||
device: cpu
|
|
||||||
|
|
||||||
- platform: linux/amd64
|
|
||||||
runner: ubuntu-latest
|
|
||||||
device: cuda
|
|
||||||
suffix: -cuda
|
|
||||||
|
|
||||||
- platform: linux/amd64
|
|
||||||
runner: mich
|
|
||||||
device: rocm
|
|
||||||
suffix: -rocm
|
|
||||||
|
|
||||||
- platform: linux/amd64
|
|
||||||
runner: ubuntu-latest
|
|
||||||
device: openvino
|
|
||||||
suffix: -openvino
|
|
||||||
|
|
||||||
- platform: linux/arm64
|
|
||||||
runner: ubuntu-24.04-arm
|
|
||||||
device: armnn
|
|
||||||
suffix: -armnn
|
|
||||||
|
|
||||||
- platform: linux/arm64
|
|
||||||
runner: ubuntu-24.04-arm
|
|
||||||
device: rknn
|
|
||||||
suffix: -rknn
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Prepare
|
|
||||||
run: |
|
|
||||||
platform=${{ matrix.platform }}
|
|
||||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
|
||||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
|
|
||||||
if: ${{ !github.event.pull_request.head.repo.fork }}
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.repository_owner }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Generate cache key suffix
|
|
||||||
env:
|
|
||||||
REF: ${{ github.ref_name }}
|
|
||||||
run: |
|
|
||||||
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
|
||||||
echo "CACHE_KEY_SUFFIX=pr-${{ github.event.number }}" >> $GITHUB_ENV
|
|
||||||
else
|
|
||||||
SUFFIX=$(echo "${REF}" | sed 's/[^a-zA-Z0-9]/-/g')
|
|
||||||
echo "CACHE_KEY_SUFFIX=${SUFFIX}" >> $GITHUB_ENV
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Generate cache target
|
|
||||||
id: cache-target
|
|
||||||
run: |
|
|
||||||
if [[ "${{ github.event.pull_request.head.repo.fork }}" == "true" ]]; then
|
|
||||||
# Essentially just ignore the cache output (forks can't write to registry cache)
|
|
||||||
echo "cache-to=type=local,dest=/tmp/discard,ignore-error=true" >> $GITHUB_OUTPUT
|
|
||||||
else
|
|
||||||
echo "cache-to=type=registry,ref=${GHCR_REPO}-build-cache:${PLATFORM_PAIR}-${{ matrix.device }}-${CACHE_KEY_SUFFIX},mode=max,compression=zstd" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Generate docker image tags
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5
|
|
||||||
env:
|
|
||||||
DOCKER_METADATA_PR_HEAD_SHA: 'true'
|
|
||||||
|
|
||||||
- name: Build and push image
|
|
||||||
id: build
|
|
||||||
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
|
|
||||||
with:
|
|
||||||
context: ${{ env.context }}
|
|
||||||
file: ${{ env.file }}
|
|
||||||
platforms: ${{ matrix.platforms }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
cache-to: ${{ steps.cache-target.outputs.cache-to }}
|
|
||||||
cache-from: |
|
|
||||||
type=registry,ref=${{ env.GHCR_REPO }}-build-cache:${{ env.PLATFORM_PAIR }}-${{ matrix.device }}-${{ env.CACHE_KEY_SUFFIX }}
|
|
||||||
type=registry,ref=${{ env.GHCR_REPO }}-build-cache:${{ env.PLATFORM_PAIR }}-${{ matrix.device }}-main
|
|
||||||
outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=${{ !github.event.pull_request.head.repo.fork }}
|
|
||||||
build-args: |
|
|
||||||
DEVICE=${{ matrix.device }}
|
|
||||||
BUILD_ID=${{ github.run_id }}
|
|
||||||
BUILD_IMAGE=${{ github.event_name == 'release' && github.ref_name || steps.metadata.outputs.tags }}
|
|
||||||
BUILD_SOURCE_REF=${{ github.ref_name }}
|
|
||||||
BUILD_SOURCE_COMMIT=${{ github.sha }}
|
|
||||||
|
|
||||||
- name: Export digest
|
|
||||||
run: | # zizmor: ignore[template-injection]
|
|
||||||
mkdir -p ${{ runner.temp }}/digests
|
|
||||||
digest="${{ steps.build.outputs.digest }}"
|
|
||||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
|
||||||
|
|
||||||
- name: Upload digest
|
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
|
||||||
with:
|
|
||||||
name: ml-digests-${{ matrix.device }}-${{ env.PLATFORM_PAIR }}
|
|
||||||
path: ${{ runner.temp }}/digests/*
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 1
|
|
||||||
|
|
||||||
merge_ml:
|
|
||||||
name: Merge & Push ML
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
actions: read
|
|
||||||
packages: write
|
|
||||||
if: ${{ needs.pre-job.outputs.should_run_ml == 'true' && !github.event.pull_request.head.repo.fork }}
|
|
||||||
env:
|
|
||||||
GHCR_REPO: ghcr.io/${{ github.repository_owner }}/immich-machine-learning
|
|
||||||
DOCKER_REPO: altran1502/immich-machine-learning
|
|
||||||
strategy:
|
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- device: cpu
|
- device: cpu
|
||||||
|
tag-suffix: ''
|
||||||
- device: cuda
|
- device: cuda
|
||||||
suffix: -cuda
|
tag-suffix: '-cuda'
|
||||||
- device: rocm
|
platforms: linux/amd64
|
||||||
suffix: -rocm
|
|
||||||
- device: openvino
|
- device: openvino
|
||||||
suffix: -openvino
|
tag-suffix: '-openvino'
|
||||||
|
platforms: linux/amd64
|
||||||
- device: armnn
|
- device: armnn
|
||||||
suffix: -armnn
|
tag-suffix: '-armnn'
|
||||||
|
platforms: linux/arm64
|
||||||
- device: rknn
|
- device: rknn
|
||||||
suffix: -rknn
|
tag-suffix: '-rknn'
|
||||||
needs:
|
platforms: linux/arm64
|
||||||
- build_and_push_ml
|
- device: rocm
|
||||||
steps:
|
tag-suffix: '-rocm'
|
||||||
- name: Download digests
|
platforms: linux/amd64
|
||||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
runner-mapping: '{"linux/amd64": "mich"}'
|
||||||
with:
|
uses: ./.github/workflows/multi-runner-build.yml
|
||||||
path: ${{ runner.temp }}/digests
|
|
||||||
pattern: ml-digests-${{ matrix.device }}-*
|
|
||||||
merge-multiple: true
|
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
|
||||||
if: ${{ github.event_name == 'release' }}
|
|
||||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Login to GHCR
|
|
||||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.repository_owner }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3
|
|
||||||
|
|
||||||
- name: Generate docker image tags
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5
|
|
||||||
env:
|
|
||||||
DOCKER_METADATA_PR_HEAD_SHA: 'true'
|
|
||||||
with:
|
|
||||||
flavor: |
|
|
||||||
# Disable latest tag
|
|
||||||
latest=false
|
|
||||||
suffix=${{ matrix.suffix }}
|
|
||||||
images: |
|
|
||||||
name=${{ env.GHCR_REPO }}
|
|
||||||
name=${{ env.DOCKER_REPO }},enable=${{ github.event_name == 'release' }}
|
|
||||||
tags: |
|
|
||||||
# Tag with branch name
|
|
||||||
type=ref,event=branch
|
|
||||||
# Tag with pr-number
|
|
||||||
type=ref,event=pr
|
|
||||||
# Tag with long commit sha hash
|
|
||||||
type=sha,format=long,prefix=commit-
|
|
||||||
# Tag with git tag on release
|
|
||||||
type=ref,event=tag
|
|
||||||
type=raw,value=release,enable=${{ github.event_name == 'release' }}
|
|
||||||
|
|
||||||
- name: Create manifest list and push
|
|
||||||
working-directory: ${{ runner.temp }}/digests
|
|
||||||
run: |
|
|
||||||
# Process annotations
|
|
||||||
declare -a ANNOTATIONS=()
|
|
||||||
if [[ -n "$DOCKER_METADATA_OUTPUT_JSON" ]]; then
|
|
||||||
while IFS= read -r annotation; do
|
|
||||||
# Extract key and value by removing the manifest: prefix
|
|
||||||
if [[ "$annotation" =~ ^manifest:(.+)=(.+)$ ]]; then
|
|
||||||
key="${BASH_REMATCH[1]}"
|
|
||||||
value="${BASH_REMATCH[2]}"
|
|
||||||
# Use array to properly handle arguments with spaces
|
|
||||||
ANNOTATIONS+=(--annotation "index:$key=$value")
|
|
||||||
fi
|
|
||||||
done < <(jq -r '.annotations[]' <<< "$DOCKER_METADATA_OUTPUT_JSON")
|
|
||||||
fi
|
|
||||||
|
|
||||||
TAGS=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
|
||||||
SOURCE_ARGS=$(printf "${GHCR_REPO}@sha256:%s " *)
|
|
||||||
|
|
||||||
docker buildx imagetools create $TAGS "${ANNOTATIONS[@]}" $SOURCE_ARGS
|
|
||||||
|
|
||||||
build_and_push_server:
|
|
||||||
name: Build and Push Server
|
|
||||||
runs-on: ${{ matrix.runner }}
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
needs: pre-job
|
|
||||||
if: ${{ needs.pre-job.outputs.should_run_server == 'true' }}
|
|
||||||
env:
|
|
||||||
image: immich-server
|
|
||||||
context: .
|
|
||||||
file: server/Dockerfile
|
|
||||||
GHCR_REPO: ghcr.io/${{ github.repository_owner }}/immich-server
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- platform: linux/amd64
|
|
||||||
runner: ubuntu-latest
|
|
||||||
- platform: linux/arm64
|
|
||||||
runner: ubuntu-24.04-arm
|
|
||||||
steps:
|
|
||||||
- name: Prepare
|
|
||||||
run: |
|
|
||||||
platform=${{ matrix.platform }}
|
|
||||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3
|
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
|
||||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
|
|
||||||
if: ${{ !github.event.pull_request.head.repo.fork }}
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.repository_owner }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Generate cache key suffix
|
|
||||||
env:
|
|
||||||
REF: ${{ github.ref_name }}
|
|
||||||
run: |
|
|
||||||
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
|
||||||
echo "CACHE_KEY_SUFFIX=pr-${{ github.event.number }}" >> $GITHUB_ENV
|
|
||||||
else
|
|
||||||
SUFFIX=$(echo "${REF}" | sed 's/[^a-zA-Z0-9]/-/g')
|
|
||||||
echo "CACHE_KEY_SUFFIX=${SUFFIX}" >> $GITHUB_ENV
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Generate cache target
|
|
||||||
id: cache-target
|
|
||||||
run: |
|
|
||||||
if [[ "${{ github.event.pull_request.head.repo.fork }}" == "true" ]]; then
|
|
||||||
# Essentially just ignore the cache output (forks can't write to registry cache)
|
|
||||||
echo "cache-to=type=local,dest=/tmp/discard,ignore-error=true" >> $GITHUB_OUTPUT
|
|
||||||
else
|
|
||||||
echo "cache-to=type=registry,ref=${GHCR_REPO}-build-cache:${PLATFORM_PAIR}-${CACHE_KEY_SUFFIX},mode=max,compression=zstd" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Generate docker image tags
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5
|
|
||||||
env:
|
|
||||||
DOCKER_METADATA_PR_HEAD_SHA: 'true'
|
|
||||||
|
|
||||||
- name: Build and push image
|
|
||||||
id: build
|
|
||||||
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
|
|
||||||
with:
|
|
||||||
context: ${{ env.context }}
|
|
||||||
file: ${{ env.file }}
|
|
||||||
platforms: ${{ matrix.platform }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
cache-to: ${{ steps.cache-target.outputs.cache-to }}
|
|
||||||
cache-from: |
|
|
||||||
type=registry,ref=${{ env.GHCR_REPO }}-build-cache:${{ env.PLATFORM_PAIR }}-${{ env.CACHE_KEY_SUFFIX }}
|
|
||||||
type=registry,ref=${{ env.GHCR_REPO }}-build-cache:${{ env.PLATFORM_PAIR }}-main
|
|
||||||
outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=${{ !github.event.pull_request.head.repo.fork }}
|
|
||||||
build-args: |
|
|
||||||
DEVICE=cpu
|
|
||||||
BUILD_ID=${{ github.run_id }}
|
|
||||||
BUILD_IMAGE=${{ github.event_name == 'release' && github.ref_name || steps.metadata.outputs.tags }}
|
|
||||||
BUILD_SOURCE_REF=${{ github.ref_name }}
|
|
||||||
BUILD_SOURCE_COMMIT=${{ github.sha }}
|
|
||||||
|
|
||||||
- name: Export digest
|
|
||||||
run: | # zizmor: ignore[template-injection]
|
|
||||||
mkdir -p ${{ runner.temp }}/digests
|
|
||||||
digest="${{ steps.build.outputs.digest }}"
|
|
||||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
|
||||||
|
|
||||||
- name: Upload digest
|
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
|
||||||
with:
|
|
||||||
name: server-digests-${{ env.PLATFORM_PAIR }}
|
|
||||||
path: ${{ runner.temp }}/digests/*
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 1
|
|
||||||
|
|
||||||
merge_server:
|
|
||||||
name: Merge & Push Server
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
actions: read
|
actions: read
|
||||||
packages: write
|
packages: write
|
||||||
if: ${{ needs.pre-job.outputs.should_run_server == 'true' && !github.event.pull_request.head.repo.fork }}
|
secrets:
|
||||||
env:
|
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
GHCR_REPO: ghcr.io/${{ github.repository_owner }}/immich-server
|
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
DOCKER_REPO: altran1502/immich-server
|
|
||||||
needs:
|
|
||||||
- build_and_push_server
|
|
||||||
steps:
|
|
||||||
- name: Download digests
|
|
||||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
|
||||||
with:
|
with:
|
||||||
path: ${{ runner.temp }}/digests
|
image: immich-machine-learning
|
||||||
pattern: server-digests-*
|
context: machine-learning
|
||||||
merge-multiple: true
|
dockerfile: machine-learning/Dockerfile
|
||||||
|
platforms: ${{ matrix.platforms }}
|
||||||
|
runner-mapping: ${{ matrix.runner-mapping }}
|
||||||
|
tag-suffix: ${{ matrix.tag-suffix }}
|
||||||
|
dockerhub-push: ${{ github.event_name == 'release' }}
|
||||||
|
build-args: |
|
||||||
|
DEVICE=${{ matrix.device }}
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
server:
|
||||||
if: ${{ github.event_name == 'release' }}
|
name: Build and Push Server
|
||||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
|
needs: pre-job
|
||||||
|
if: ${{ needs.pre-job.outputs.should_run_server == 'true' }}
|
||||||
|
uses: ./.github/workflows/multi-runner-build.yml
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
actions: read
|
||||||
|
packages: write
|
||||||
|
secrets:
|
||||||
|
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
image: immich-server
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
context: .
|
||||||
|
dockerfile: server/Dockerfile
|
||||||
- name: Login to GHCR
|
dockerhub-push: ${{ github.event_name == 'release' }}
|
||||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
|
build-args: |
|
||||||
with:
|
DEVICE=cpu
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.repository_owner }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3
|
|
||||||
|
|
||||||
- name: Generate docker image tags
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5
|
|
||||||
env:
|
|
||||||
DOCKER_METADATA_PR_HEAD_SHA: 'true'
|
|
||||||
with:
|
|
||||||
flavor: |
|
|
||||||
# Disable latest tag
|
|
||||||
latest=false
|
|
||||||
suffix=${{ matrix.suffix }}
|
|
||||||
images: |
|
|
||||||
name=${{ env.GHCR_REPO }}
|
|
||||||
name=${{ env.DOCKER_REPO }},enable=${{ github.event_name == 'release' }}
|
|
||||||
tags: |
|
|
||||||
# Tag with branch name
|
|
||||||
type=ref,event=branch
|
|
||||||
# Tag with pr-number
|
|
||||||
type=ref,event=pr
|
|
||||||
# Tag with long commit sha hash
|
|
||||||
type=sha,format=long,prefix=commit-
|
|
||||||
# Tag with git tag on release
|
|
||||||
type=ref,event=tag
|
|
||||||
type=raw,value=release,enable=${{ github.event_name == 'release' }}
|
|
||||||
|
|
||||||
- name: Create manifest list and push
|
|
||||||
working-directory: ${{ runner.temp }}/digests
|
|
||||||
run: |
|
|
||||||
# Process annotations
|
|
||||||
declare -a ANNOTATIONS=()
|
|
||||||
if [[ -n "$DOCKER_METADATA_OUTPUT_JSON" ]]; then
|
|
||||||
while IFS= read -r annotation; do
|
|
||||||
# Extract key and value by removing the manifest: prefix
|
|
||||||
if [[ "$annotation" =~ ^manifest:(.+)=(.+)$ ]]; then
|
|
||||||
key="${BASH_REMATCH[1]}"
|
|
||||||
value="${BASH_REMATCH[2]}"
|
|
||||||
# Use array to properly handle arguments with spaces
|
|
||||||
ANNOTATIONS+=(--annotation "index:$key=$value")
|
|
||||||
fi
|
|
||||||
done < <(jq -r '.annotations[]' <<< "$DOCKER_METADATA_OUTPUT_JSON")
|
|
||||||
fi
|
|
||||||
|
|
||||||
TAGS=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
|
||||||
SOURCE_ARGS=$(printf "${GHCR_REPO}@sha256:%s " *)
|
|
||||||
|
|
||||||
docker buildx imagetools create $TAGS "${ANNOTATIONS[@]}" $SOURCE_ARGS
|
|
||||||
|
|
||||||
success-check-server:
|
success-check-server:
|
||||||
name: Docker Build & Push Server Success
|
name: Docker Build & Push Server Success
|
||||||
needs: [merge_server, retag_server]
|
needs: [server, retag_server]
|
||||||
permissions: {}
|
permissions: {}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: always()
|
if: always()
|
||||||
@ -540,7 +187,7 @@ jobs:
|
|||||||
|
|
||||||
success-check-ml:
|
success-check-ml:
|
||||||
name: Docker Build & Push ML Success
|
name: Docker Build & Push ML Success
|
||||||
needs: [merge_ml, retag_ml]
|
needs: [machine-learning, retag_ml]
|
||||||
permissions: {}
|
permissions: {}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: always()
|
if: always()
|
||||||
|
185
.github/workflows/multi-runner-build.yml
vendored
Normal file
185
.github/workflows/multi-runner-build.yml
vendored
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
name: 'Multi-runner container image build'
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
image:
|
||||||
|
description: 'Name of the image'
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
context:
|
||||||
|
description: 'Path to build context'
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
dockerfile:
|
||||||
|
description: 'Path to Dockerfile'
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
tag-suffix:
|
||||||
|
description: 'Suffix to append to the image tag'
|
||||||
|
type: string
|
||||||
|
default: ''
|
||||||
|
dockerhub-push:
|
||||||
|
description: 'Push to Docker Hub'
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
build-args:
|
||||||
|
description: 'Docker build arguments'
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
platforms:
|
||||||
|
description: 'Platforms to build for'
|
||||||
|
type: string
|
||||||
|
runner-mapping:
|
||||||
|
description: 'Mapping from platforms to runners'
|
||||||
|
type: string
|
||||||
|
secrets:
|
||||||
|
DOCKERHUB_USERNAME:
|
||||||
|
required: false
|
||||||
|
DOCKERHUB_TOKEN:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ inputs.image }}
|
||||||
|
DOCKERHUB_IMAGE: altran1502/${{ inputs.image }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
matrix:
|
||||||
|
name: 'Generate matrix'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
matrix: ${{ steps.matrix.outputs.matrix }}
|
||||||
|
key: ${{ steps.artifact-key.outputs.base }}
|
||||||
|
steps:
|
||||||
|
- name: Generate build matrix
|
||||||
|
id: matrix
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
PLATFORMS: ${{ inputs.platforms || 'linux/amd64,linux/arm64' }}
|
||||||
|
RUNNER_MAPPING: ${{ inputs.runner-mapping || '{"linux/amd64":"ubuntu-latest","linux/arm64":"ubuntu-24.04-arm"}' }}
|
||||||
|
run: |
|
||||||
|
matrix=$(jq -R -c \
|
||||||
|
--argjson runner_mapping "${RUNNER_MAPPING}" \
|
||||||
|
'split(",") | map({platform: ., runner: $runner_mapping[.]})' \
|
||||||
|
<<< "${PLATFORMS}")
|
||||||
|
echo "${matrix}"
|
||||||
|
echo "matrix=${matrix}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Determine artifact key
|
||||||
|
id: artifact-key
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
IMAGE: ${{ inputs.image }}
|
||||||
|
SUFFIX: ${{ inputs.tag-suffix }}
|
||||||
|
run: |
|
||||||
|
if [[ -n "${SUFFIX}" ]]; then
|
||||||
|
base="${IMAGE}${SUFFIX}-digests"
|
||||||
|
else
|
||||||
|
base="${IMAGE}-digests"
|
||||||
|
fi
|
||||||
|
echo "${base}"
|
||||||
|
echo "base=${base}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
build:
|
||||||
|
needs: matrix
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include: ${{ fromJson(needs.matrix.outputs.matrix) }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- uses: ./.github/actions/image-build
|
||||||
|
with:
|
||||||
|
context: ${{ inputs.context }}
|
||||||
|
dockerfile: ${{ inputs.dockerfile }}
|
||||||
|
image: ${{ env.GHCR_IMAGE }}
|
||||||
|
ghcr-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
platform: ${{ matrix.platform }}
|
||||||
|
artifact-key-base: ${{ needs.matrix.outputs.key }}
|
||||||
|
build-args: ${{ inputs.build-args }}
|
||||||
|
|
||||||
|
merge:
|
||||||
|
needs: [matrix, build]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: ${{ !github.event.pull_request.head.repo.fork }}
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
actions: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- name: Download digests
|
||||||
|
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4
|
||||||
|
with:
|
||||||
|
path: ${{ runner.temp }}/digests
|
||||||
|
pattern: ${{ needs.matrix.outputs.key }}-*
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Login to Docker Hub
|
||||||
|
if: ${{ inputs.dockerhub-push }}
|
||||||
|
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Login to GHCR
|
||||||
|
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3
|
||||||
|
|
||||||
|
- name: Generate docker image tags
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5
|
||||||
|
env:
|
||||||
|
DOCKER_METADATA_PR_HEAD_SHA: 'true'
|
||||||
|
with:
|
||||||
|
flavor: |
|
||||||
|
# Disable latest tag
|
||||||
|
latest=false
|
||||||
|
suffix=${{ inputs.tag-suffix }}
|
||||||
|
images: |
|
||||||
|
name=${{ env.GHCR_IMAGE }}
|
||||||
|
name=${{ env.DOCKERHUB_IMAGE }},enable=${{ inputs.dockerhub-push }}
|
||||||
|
tags: |
|
||||||
|
# Tag with branch name
|
||||||
|
type=ref,event=branch
|
||||||
|
# Tag with pr-number
|
||||||
|
type=ref,event=pr
|
||||||
|
# Tag with long commit sha hash
|
||||||
|
type=sha,format=long,prefix=commit-
|
||||||
|
# Tag with git tag on release
|
||||||
|
type=ref,event=tag
|
||||||
|
type=raw,value=release,enable=${{ github.event_name == 'release' }}
|
||||||
|
|
||||||
|
- name: Create manifest list and push
|
||||||
|
working-directory: ${{ runner.temp }}/digests
|
||||||
|
run: |
|
||||||
|
# Process annotations
|
||||||
|
declare -a ANNOTATIONS=()
|
||||||
|
if [[ -n "$DOCKER_METADATA_OUTPUT_JSON" ]]; then
|
||||||
|
while IFS= read -r annotation; do
|
||||||
|
# Extract key and value by removing the manifest: prefix
|
||||||
|
if [[ "$annotation" =~ ^manifest:(.+)=(.+)$ ]]; then
|
||||||
|
key="${BASH_REMATCH[1]}"
|
||||||
|
value="${BASH_REMATCH[2]}"
|
||||||
|
# Use array to properly handle arguments with spaces
|
||||||
|
ANNOTATIONS+=(--annotation "index:$key=$value")
|
||||||
|
fi
|
||||||
|
done < <(jq -r '.annotations[]' <<< "$DOCKER_METADATA_OUTPUT_JSON")
|
||||||
|
fi
|
||||||
|
|
||||||
|
TAGS=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||||
|
SOURCE_ARGS=$(printf "${GHCR_IMAGE}@sha256:%s " *)
|
||||||
|
|
||||||
|
docker buildx imagetools create $TAGS "${ANNOTATIONS[@]}" $SOURCE_ARGS
|
986
cli/package-lock.json
generated
986
cli/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -72,7 +72,7 @@ In rare cases, the library watcher can hang, preventing Immich from starting up.
|
|||||||
|
|
||||||
### Nightly job
|
### Nightly job
|
||||||
|
|
||||||
There is an automatic scan job that is scheduled to run once a day. This job also cleans up any libraries stuck in deletion. It is possible to trigger the cleanup by clicking "Scan all libraries" in the library managment page.
|
There is an automatic scan job that is scheduled to run once a day. This job also cleans up any libraries stuck in deletion. It is possible to trigger the cleanup by clicking "Scan all libraries" in the library management page.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
|
@ -92,7 +92,7 @@ Memory and execution time estimates were obtained without acceleration on a 7800
|
|||||||
|
|
||||||
**Execution Time (ms)**: After warming up the model with one pass, the mean execution time of 100 passes with the same input.
|
**Execution Time (ms)**: After warming up the model with one pass, the mean execution time of 100 passes with the same input.
|
||||||
|
|
||||||
**Memory (MiB)**: The peak RSS usage of the process afer performing the above timing benchmark. Does not include image decoding, concurrent processing, the web server, etc., which are relatively constant factors.
|
**Memory (MiB)**: The peak RSS usage of the process after performing the above timing benchmark. Does not include image decoding, concurrent processing, the web server, etc., which are relatively constant factors.
|
||||||
|
|
||||||
**Recall (%)**: Evaluated on Crossmodal-3600, the average of the recall@1, recall@5 and recall@10 results for zeroshot image retrieval. Chinese (Simplified), English, French, German, Italian, Japanese, Korean, Polish, Russian, Spanish and Turkish are additionally tested on XTD-10. Chinese (Simplified) and English are additionally tested on Flickr30k. The recall metrics are the average across all tested datasets.
|
**Recall (%)**: Evaluated on Crossmodal-3600, the average of the recall@1, recall@5 and recall@10 results for zeroshot image retrieval. Chinese (Simplified), English, French, German, Italian, Japanese, Korean, Polish, Russian, Spanish and Turkish are additionally tested on XTD-10. Chinese (Simplified) and English are additionally tested on Flickr30k. The recall metrics are the average across all tested datasets.
|
||||||
|
|
||||||
|
@ -2,53 +2,13 @@
|
|||||||
sidebar_position: 30
|
sidebar_position: 30
|
||||||
---
|
---
|
||||||
|
|
||||||
import CodeBlock from '@theme/CodeBlock';
|
|
||||||
import ExampleEnv from '!!raw-loader!../../../docker/example.env';
|
|
||||||
|
|
||||||
# Docker Compose [Recommended]
|
# Docker Compose [Recommended]
|
||||||
|
|
||||||
Docker Compose is the recommended method to run Immich in production. Below are the steps to deploy Immich with Docker Compose.
|
Docker Compose is the recommended method to run Immich in production. Below are the steps to deploy Immich with Docker Compose.
|
||||||
|
|
||||||
## Step 1 - Download the required files
|
import DockerComposeSteps from '/docs/partials/_docker-compose-install-steps.mdx';
|
||||||
|
|
||||||
Create a directory of your choice (e.g. `./immich-app`) to hold the `docker-compose.yml` and `.env` files.
|
<DockerComposeSteps />
|
||||||
|
|
||||||
```bash title="Move to the directory you created"
|
|
||||||
mkdir ./immich-app
|
|
||||||
cd ./immich-app
|
|
||||||
```
|
|
||||||
|
|
||||||
Download [`docker-compose.yml`][compose-file] and [`example.env`][env-file] by running the following commands:
|
|
||||||
|
|
||||||
```bash title="Get docker-compose.yml file"
|
|
||||||
wget -O docker-compose.yml https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash title="Get .env file"
|
|
||||||
wget -O .env https://github.com/immich-app/immich/releases/latest/download/example.env
|
|
||||||
```
|
|
||||||
|
|
||||||
You can alternatively download these two files from your browser and move them to the directory that you created, in which case ensure that you rename `example.env` to `.env`.
|
|
||||||
|
|
||||||
## Step 2 - Populate the .env file with custom values
|
|
||||||
|
|
||||||
<CodeBlock language="bash" title="Default environmental variable content">
|
|
||||||
{ExampleEnv}
|
|
||||||
</CodeBlock>
|
|
||||||
|
|
||||||
- Populate `UPLOAD_LOCATION` with your preferred location for storing backup assets. It should be a new directory on the server with enough free space.
|
|
||||||
- Consider changing `DB_PASSWORD` to a custom value. Postgres is not publicly exposed, so this password is only used for local authentication.
|
|
||||||
To avoid issues with Docker parsing this value, it is best to use only the characters `A-Za-z0-9`. `pwgen` is a handy utility for this.
|
|
||||||
- Set your timezone by uncommenting the `TZ=` line.
|
|
||||||
- Populate custom database information if necessary.
|
|
||||||
|
|
||||||
## Step 3 - Start the containers
|
|
||||||
|
|
||||||
From the directory you created in Step 1 (which should now contain your customized `docker-compose.yml` and `.env` files), run the following command to start Immich as a background service:
|
|
||||||
|
|
||||||
```bash title="Start the containers"
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
:::info Docker version
|
:::info Docker version
|
||||||
If you get an error such as `unknown shorthand flag: 'd' in -d` or `open <location of your .env file>: permission denied`, you are probably running the wrong Docker version. (This happens, for example, with the docker.io package in Ubuntu 22.04.3 LTS.) You can correct the problem by following the complete [Docker Engine install](https://docs.docker.com/engine/install/) procedure for your distribution, crucially the "Uninstall old versions" and "Install using the apt/rpm repository" sections. These replace the distro's Docker packages with Docker's official ones.
|
If you get an error such as `unknown shorthand flag: 'd' in -d` or `open <location of your .env file>: permission denied`, you are probably running the wrong Docker version. (This happens, for example, with the docker.io package in Ubuntu 22.04.3 LTS.) You can correct the problem by following the complete [Docker Engine install](https://docs.docker.com/engine/install/) procedure for your distribution, crucially the "Uninstall old versions" and "Install using the apt/rpm repository" sections. These replace the distro's Docker packages with Docker's official ones.
|
||||||
@ -70,6 +30,3 @@ If you get an error `can't set healthcheck.start_interval as feature require Doc
|
|||||||
## Next Steps
|
## Next Steps
|
||||||
|
|
||||||
Read the [Post Installation](/docs/install/post-install.mdx) steps and [upgrade instructions](/docs/install/upgrading.md).
|
Read the [Post Installation](/docs/install/post-install.mdx) steps and [upgrade instructions](/docs/install/upgrading.md).
|
||||||
|
|
||||||
[compose-file]: https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml
|
|
||||||
[env-file]: https://github.com/immich-app/immich/releases/latest/download/example.env
|
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
sidebar_position: 80
|
sidebar_position: 80
|
||||||
---
|
---
|
||||||
|
|
||||||
# TrueNAS SCALE [Community]
|
# TrueNAS [Community]
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
This is a community contribution and not officially supported by the Immich team, but included here for convenience.
|
This is a community contribution and not officially supported by the Immich team, but included here for convenience.
|
||||||
@ -12,17 +12,17 @@ Community support can be found in the dedicated channel on the [Discord Server](
|
|||||||
**Please report app issues to the corresponding [Github Repository](https://github.com/truenas/charts/tree/master/community/immich).**
|
**Please report app issues to the corresponding [Github Repository](https://github.com/truenas/charts/tree/master/community/immich).**
|
||||||
:::
|
:::
|
||||||
|
|
||||||
Immich can easily be installed on TrueNAS SCALE via the **Community** train application.
|
Immich can easily be installed on TrueNAS Community Edition via the **Community** train application.
|
||||||
Consider reviewing the TrueNAS [Apps tutorial](https://www.truenas.com/docs/scale/scaletutorials/apps/) if you have not previously configured applications on your system.
|
Consider reviewing the TrueNAS [Apps resources](https://apps.truenas.com/getting-started/) if you have not previously configured applications on your system.
|
||||||
|
|
||||||
TrueNAS SCALE makes installing and updating Immich easy, but you must use the Immich web portal and mobile app to configure accounts and access libraries.
|
TrueNAS Community Edition makes installing and updating Immich easy, but you must use the Immich web portal and mobile app to configure accounts and access libraries.
|
||||||
|
|
||||||
## First Steps
|
## First Steps
|
||||||
|
|
||||||
The Immich app in TrueNAS SCALE installs, completes the initial configuration, then starts the Immich web portal.
|
The Immich app in TrueNAS Community Edition installs, completes the initial configuration, then starts the Immich web portal.
|
||||||
When updates become available, SCALE alerts and provides easy updates.
|
When updates become available, TrueNAS alerts and provides easy updates.
|
||||||
|
|
||||||
Before installing the Immich app in SCALE, review the [Environment Variables](#environment-variables) documentation to see if you want to configure any during installation.
|
Before installing the Immich app in TrueNAS, review the [Environment Variables](#environment-variables) documentation to see if you want to configure any during installation.
|
||||||
You may also configure environment variables at any time after deploying the application.
|
You may also configure environment variables at any time after deploying the application.
|
||||||
|
|
||||||
### Setting up Storage Datasets
|
### Setting up Storage Datasets
|
||||||
@ -126,9 +126,9 @@ className="border rounded-xl"
|
|||||||
|
|
||||||
Accept the default port `30041` in **WebUI Port** or enter a custom port number.
|
Accept the default port `30041` in **WebUI Port** or enter a custom port number.
|
||||||
:::info Allowed Port Numbers
|
:::info Allowed Port Numbers
|
||||||
Only numbers within the range 9000-65535 may be used on SCALE versions below TrueNAS Scale 24.10 Electric Eel.
|
Only numbers within the range 9000-65535 may be used on TrueNAS versions below TrueNAS Community Edition 24.10 Electric Eel.
|
||||||
|
|
||||||
Regardless of version, to avoid port conflicts, don't use [ports on this list](https://www.truenas.com/docs/references/defaultports/).
|
Regardless of version, to avoid port conflicts, don't use [ports on this list](https://www.truenas.com/docs/solutions/optimizations/security/#truenas-default-ports).
|
||||||
:::
|
:::
|
||||||
|
|
||||||
### Storage Configuration
|
### Storage Configuration
|
||||||
@ -173,7 +173,7 @@ className="border rounded-xl"
|
|||||||
|
|
||||||
You may configure [External Libraries](/docs/features/libraries) by mounting them using **Additional Storage**.
|
You may configure [External Libraries](/docs/features/libraries) by mounting them using **Additional Storage**.
|
||||||
The **Mount Path** is the location you will need to copy and paste into the External Library settings within Immich.
|
The **Mount Path** is the location you will need to copy and paste into the External Library settings within Immich.
|
||||||
The **Host Path** is the location on the TrueNAS SCALE server where your external library is located.
|
The **Host Path** is the location on the TrueNAS Community Edition server where your external library is located.
|
||||||
|
|
||||||
<!-- A section for Labels would go here but I don't know what they do. -->
|
<!-- A section for Labels would go here but I don't know what they do. -->
|
||||||
|
|
||||||
@ -188,17 +188,17 @@ className="border rounded-xl"
|
|||||||
|
|
||||||
Accept the default **CPU** limit of `2` threads or specify the number of threads (CPUs with Multi-/Hyper-threading have 2 threads per core).
|
Accept the default **CPU** limit of `2` threads or specify the number of threads (CPUs with Multi-/Hyper-threading have 2 threads per core).
|
||||||
|
|
||||||
Accept the default **Memory** limit of `4096` MB or specify the number of MB of RAM. If you're using Machine Learning you should probably set this above 8000 MB.
|
Specify the **Memory** limit in MB of RAM. Immich recommends at least 6000 MB (6GB). If you selected **Enable Machine Learning** in **Immich Configuration**, you should probably set this above 8000 MB.
|
||||||
|
|
||||||
:::info Older SCALE Versions
|
:::info Older TrueNAS Versions
|
||||||
Before TrueNAS SCALE version 24.10 Electric Eel:
|
Before TrueNAS Community Edition version 24.10 Electric Eel:
|
||||||
|
|
||||||
The **CPU** value was specified in a different format with a default of `4000m` which is 4 threads.
|
The **CPU** value was specified in a different format with a default of `4000m` which is 4 threads.
|
||||||
|
|
||||||
The **Memory** value was specified in a different format with a default of `8Gi` which is 8 GiB of RAM. The value was specified in bytes or a number with a measurement suffix. Examples: `129M`, `123Mi`, `1000000000`
|
The **Memory** value was specified in a different format with a default of `8Gi` which is 8 GiB of RAM. The value was specified in bytes or a number with a measurement suffix. Examples: `129M`, `123Mi`, `1000000000`
|
||||||
:::
|
:::
|
||||||
|
|
||||||
Enable **GPU Configuration** options if you have a GPU that you will use for [Hardware Transcoding](/docs/features/hardware-transcoding) and/or [Hardware-Accelerated Machine Learning](/docs/features/ml-hardware-acceleration.md). More info: [GPU Passthrough Docs for TrueNAS Apps](https://www.truenas.com/docs/truenasapps/#gpu-passthrough)
|
Enable **GPU Configuration** options if you have a GPU that you will use for [Hardware Transcoding](/docs/features/hardware-transcoding) and/or [Hardware-Accelerated Machine Learning](/docs/features/ml-hardware-acceleration.md). More info: [GPU Passthrough Docs for TrueNAS Apps](https://apps.truenas.com/managing-apps/installing-apps/#gpu-passthrough)
|
||||||
|
|
||||||
### Install
|
### Install
|
||||||
|
|
||||||
@ -240,7 +240,7 @@ className="border rounded-xl"
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
:::info
|
:::info
|
||||||
Some Environment Variables are not available for the TrueNAS SCALE app. This is mainly because they can be configured through GUI options in the [Edit Immich screen](#edit-app-settings).
|
Some Environment Variables are not available for the TrueNAS Community Edition app. This is mainly because they can be configured through GUI options in the [Edit Immich screen](#edit-app-settings).
|
||||||
|
|
||||||
Some examples are: `IMMICH_VERSION`, `UPLOAD_LOCATION`, `DB_DATA_LOCATION`, `TZ`, `IMMICH_LOG_LEVEL`, `DB_PASSWORD`, `REDIS_PASSWORD`.
|
Some examples are: `IMMICH_VERSION`, `UPLOAD_LOCATION`, `DB_DATA_LOCATION`, `TZ`, `IMMICH_LOG_LEVEL`, `DB_PASSWORD`, `REDIS_PASSWORD`.
|
||||||
:::
|
:::
|
||||||
@ -251,7 +251,7 @@ Some examples are: `IMMICH_VERSION`, `UPLOAD_LOCATION`, `DB_DATA_LOCATION`, `TZ`
|
|||||||
Make sure to read the general [upgrade instructions](/docs/install/upgrading.md).
|
Make sure to read the general [upgrade instructions](/docs/install/upgrading.md).
|
||||||
:::
|
:::
|
||||||
|
|
||||||
When updates become available, SCALE alerts and provides easy updates.
|
When updates become available, TrueNAS alerts and provides easy updates.
|
||||||
To update the app to the latest version:
|
To update the app to the latest version:
|
||||||
|
|
||||||
- Go to the **Installed Applications** screen and select Immich from the list of installed applications.
|
- Go to the **Installed Applications** screen and select Immich from the list of installed applications.
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
sidebar_position: 2
|
sidebar_position: 3
|
||||||
---
|
---
|
||||||
|
|
||||||
# Comparison
|
# Comparison
|
||||||
|
BIN
docs/docs/overview/img/social-preview-light.webp
Normal file
BIN
docs/docs/overview/img/social-preview-light.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 233 KiB |
@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
sidebar_position: 3
|
sidebar_position: 2
|
||||||
---
|
---
|
||||||
|
|
||||||
# Quick start
|
# Quick start
|
||||||
@ -10,11 +10,20 @@ to install and use it.
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
Check the [requirements page](/docs/install/requirements) to get started.
|
- A system with at least 4GB of RAM and 2 CPU cores.
|
||||||
|
- [Docker](https://docs.docker.com/engine/install/)
|
||||||
|
|
||||||
|
> For a more detailed list of requirements, see the [requirements page](/docs/install/requirements).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Set up the server
|
## Set up the server
|
||||||
|
|
||||||
Follow the [Docker Compose (Recommended)](/docs/install/docker-compose) instructions to install the server.
|
import DockerComposeSteps from '/docs/partials/_docker-compose-install-steps.mdx';
|
||||||
|
|
||||||
|
<DockerComposeSteps />
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Try the web app
|
## Try the web app
|
||||||
|
|
||||||
@ -26,6 +35,8 @@ Try uploading a picture from your browser.
|
|||||||
|
|
||||||
<img src={require('./img/upload-button.webp').default} title="Upload button" />
|
<img src={require('./img/upload-button.webp').default} title="Upload button" />
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Try the mobile app
|
## Try the mobile app
|
||||||
|
|
||||||
### Download the Mobile App
|
### Download the Mobile App
|
||||||
@ -56,6 +67,8 @@ You can select the **Jobs** tab to see Immich processing your photos.
|
|||||||
|
|
||||||
<img src={require('/docs/guides/img/jobs-tab.webp').default} title="Jobs tab" width={300} />
|
<img src={require('/docs/guides/img/jobs-tab.webp').default} title="Jobs tab" width={300} />
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Review the database backup and restore process
|
## Review the database backup and restore process
|
||||||
|
|
||||||
Immich has built-in database backups. You can refer to the
|
Immich has built-in database backups. You can refer to the
|
||||||
@ -65,6 +78,8 @@ Immich has built-in database backups. You can refer to the
|
|||||||
The database only contains metadata and user information. You must setup manual backups of the images and videos stored in `UPLOAD_LOCATION`.
|
The database only contains metadata and user information. You must setup manual backups of the images and videos stored in `UPLOAD_LOCATION`.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Where to go from here?
|
## Where to go from here?
|
||||||
|
|
||||||
You may decide you'd like to install the server a different way; the Install category on the left menu provides many options.
|
You may decide you'd like to install the server a different way; the Install category on the left menu provides many options.
|
||||||
|
@ -2,9 +2,13 @@
|
|||||||
sidebar_position: 1
|
sidebar_position: 1
|
||||||
---
|
---
|
||||||
|
|
||||||
# Introduction
|
# Welcome to Immich
|
||||||
|
|
||||||
<img src={require('./img/feature-panel.webp').default} alt="Immich - Self-hosted photos and videos backup tool" />
|
<img
|
||||||
|
src={require('./img/social-preview-light.webp').default}
|
||||||
|
alt="Immich - Self-hosted photos and videos backup tool"
|
||||||
|
data-theme="light"
|
||||||
|
/>
|
||||||
|
|
||||||
## Welcome!
|
## Welcome!
|
||||||
|
|
43
docs/docs/partials/_docker-compose-install-steps.mdx
Normal file
43
docs/docs/partials/_docker-compose-install-steps.mdx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import CodeBlock from '@theme/CodeBlock';
|
||||||
|
import ExampleEnv from '!!raw-loader!../../../docker/example.env';
|
||||||
|
|
||||||
|
### Step 1 - Download the required files
|
||||||
|
|
||||||
|
Create a directory of your choice (e.g. `./immich-app`) to hold the `docker-compose.yml` and `.env` files.
|
||||||
|
|
||||||
|
```bash title="Move to the directory you created"
|
||||||
|
mkdir ./immich-app
|
||||||
|
cd ./immich-app
|
||||||
|
```
|
||||||
|
|
||||||
|
Download [`docker-compose.yml`](https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml) and [`example.env`](https://github.com/immich-app/immich/releases/latest/download/example.env) by running the following commands:
|
||||||
|
|
||||||
|
```bash title="Get docker-compose.yml file"
|
||||||
|
wget -O docker-compose.yml https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash title="Get .env file"
|
||||||
|
wget -O .env https://github.com/immich-app/immich/releases/latest/download/example.env
|
||||||
|
```
|
||||||
|
|
||||||
|
You can alternatively download these two files from your browser and move them to the directory that you created, in which case ensure that you rename `example.env` to `.env`.
|
||||||
|
|
||||||
|
### Step 2 - Populate the .env file with custom values
|
||||||
|
|
||||||
|
<CodeBlock language="bash" title="Default environmental variable content">
|
||||||
|
{ExampleEnv}
|
||||||
|
</CodeBlock>
|
||||||
|
|
||||||
|
- Populate `UPLOAD_LOCATION` with your preferred location for storing backup assets. It should be a new directory on the server with enough free space.
|
||||||
|
- Consider changing `DB_PASSWORD` to a custom value. Postgres is not publicly exposed, so this password is only used for local authentication.
|
||||||
|
To avoid issues with Docker parsing this value, it is best to use only the characters `A-Za-z0-9`. `pwgen` is a handy utility for this.
|
||||||
|
- Set your timezone by uncommenting the `TZ=` line.
|
||||||
|
- Populate custom database information if necessary.
|
||||||
|
|
||||||
|
### Step 3 - Start the containers
|
||||||
|
|
||||||
|
From the directory you created in Step 1 (which should now contain your customized `docker-compose.yml` and `.env` files), run the following command to start Immich as a background service:
|
||||||
|
|
||||||
|
```bash title="Start the containers"
|
||||||
|
docker compose up -d
|
||||||
|
```
|
@ -95,7 +95,7 @@ const config = {
|
|||||||
position: 'right',
|
position: 'right',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/docs/overview/introduction',
|
to: '/docs/overview/welcome',
|
||||||
position: 'right',
|
position: 'right',
|
||||||
label: 'Docs',
|
label: 'Docs',
|
||||||
},
|
},
|
||||||
@ -124,6 +124,12 @@ const config = {
|
|||||||
label: 'Discord',
|
label: 'Discord',
|
||||||
position: 'right',
|
position: 'right',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: 'html',
|
||||||
|
position: 'right',
|
||||||
|
value:
|
||||||
|
'<a href="https://buy.immich.app" target="_blank" class="no-underline hover:no-underline"><button class="buy-button bg-immich-primary dark:bg-immich-dark-primary text-white dark:text-black rounded-xl">Buy Immich</button></a>',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
@ -134,7 +140,7 @@ const config = {
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
label: 'Welcome',
|
label: 'Welcome',
|
||||||
to: '/docs/overview/introduction',
|
to: '/docs/overview/welcome',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Installation',
|
label: 'Installation',
|
||||||
|
@ -7,14 +7,22 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Be+Vietnam+Pro:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap');
|
@font-face {
|
||||||
|
font-family: 'Overpass';
|
||||||
body {
|
src: url('/fonts/overpass/Overpass.ttf') format('truetype-variations');
|
||||||
font-family: 'Be Vietnam Pro', serif;
|
font-weight: 1 999;
|
||||||
font-optical-sizing: auto;
|
font-style: normal;
|
||||||
/* font-size: 1.125rem;
|
|
||||||
ascent-override: 106.25%;
|
ascent-override: 106.25%;
|
||||||
size-adjust: 106.25%; */
|
size-adjust: 106.25%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Overpass Mono';
|
||||||
|
src: url('/fonts/overpass/OverpassMono.ttf') format('truetype-variations');
|
||||||
|
font-weight: 1 999;
|
||||||
|
font-style: normal;
|
||||||
|
ascent-override: 106.25%;
|
||||||
|
size-adjust: 106.25%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.breadcrumbs__link {
|
.breadcrumbs__link {
|
||||||
@ -29,6 +37,7 @@ img {
|
|||||||
|
|
||||||
/* You can override the default Infima variables here. */
|
/* You can override the default Infima variables here. */
|
||||||
:root {
|
:root {
|
||||||
|
font-family: 'Overpass', sans-serif;
|
||||||
--ifm-color-primary: #4250af;
|
--ifm-color-primary: #4250af;
|
||||||
--ifm-color-primary-dark: #4250af;
|
--ifm-color-primary-dark: #4250af;
|
||||||
--ifm-color-primary-darker: #4250af;
|
--ifm-color-primary-darker: #4250af;
|
||||||
@ -59,14 +68,12 @@ div[class^='announcementBar_'] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.menu__link {
|
.menu__link {
|
||||||
padding: 10px;
|
padding: 10px 10px 10px 16px;
|
||||||
padding-left: 16px;
|
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
margin-right: 16px;
|
margin-right: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu__list-item-collapsible {
|
.menu__list-item-collapsible {
|
||||||
border-radius: 10px;
|
|
||||||
margin-right: 16px;
|
margin-right: 16px;
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
}
|
}
|
||||||
@ -83,3 +90,12 @@ div[class*='navbar__items'] > li:has(a[class*='version-switcher-34ab39']) {
|
|||||||
code {
|
code {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.buy-button {
|
||||||
|
padding: 8px 14px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
font-family: 'Overpass', sans-serif;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 0 5px 2px rgba(181, 206, 254, 0.4);
|
||||||
|
}
|
||||||
|
@ -4,7 +4,7 @@ import Layout from '@theme/Layout';
|
|||||||
import { discordPath, discordViewBox } from '@site/src/components/svg-paths';
|
import { discordPath, discordViewBox } from '@site/src/components/svg-paths';
|
||||||
import ThemedImage from '@theme/ThemedImage';
|
import ThemedImage from '@theme/ThemedImage';
|
||||||
import Icon from '@mdi/react';
|
import Icon from '@mdi/react';
|
||||||
import { mdiAndroid } from '@mdi/js';
|
|
||||||
function HomepageHeader() {
|
function HomepageHeader() {
|
||||||
return (
|
return (
|
||||||
<header>
|
<header>
|
||||||
@ -13,11 +13,14 @@ function HomepageHeader() {
|
|||||||
<div className="w-full h-[120vh] absolute left-0 top-0 backdrop-blur-3xl bg-immich-bg/40 dark:bg-transparent"></div>
|
<div className="w-full h-[120vh] absolute left-0 top-0 backdrop-blur-3xl bg-immich-bg/40 dark:bg-transparent"></div>
|
||||||
</div>
|
</div>
|
||||||
<section className="text-center pt-12 sm:pt-24 bg-immich-bg/50 dark:bg-immich-dark-bg/80">
|
<section className="text-center pt-12 sm:pt-24 bg-immich-bg/50 dark:bg-immich-dark-bg/80">
|
||||||
|
<a href="https://futo.org" target="_blank" rel="noopener noreferrer">
|
||||||
<ThemedImage
|
<ThemedImage
|
||||||
sources={{ dark: 'img/logomark-dark.svg', light: 'img/logomark-light.svg' }}
|
sources={{ dark: 'img/logomark-dark-with-futo.svg', light: 'img/logomark-light-with-futo.svg' }}
|
||||||
className="h-[115px] w-[115px] mb-2 antialiased rounded-none"
|
className="h-[125px] w-[125px] mb-2 antialiased rounded-none"
|
||||||
alt="Immich logo"
|
alt="Immich logo"
|
||||||
/>
|
/>
|
||||||
|
</a>
|
||||||
|
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<p className="text-3xl md:text-5xl sm:leading-tight mb-1 font-extrabold text-black/90 dark:text-white px-4">
|
<p className="text-3xl md:text-5xl sm:leading-tight mb-1 font-extrabold text-black/90 dark:text-white px-4">
|
||||||
Self-hosted{' '}
|
Self-hosted{' '}
|
||||||
@ -28,7 +31,7 @@ function HomepageHeader() {
|
|||||||
solution<span className="block"></span>
|
solution<span className="block"></span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="max-w-1/4 m-auto mt-4 px-4">
|
<p className="max-w-1/4 m-auto mt-4 px-4 text-lg text-gray-700 dark:text-gray-100">
|
||||||
Easily back up, organize, and manage your photos on your own server. Immich helps you
|
Easily back up, organize, and manage your photos on your own server. Immich helps you
|
||||||
<span className="sm:block"></span> browse, search and organize your photos and videos with ease, without
|
<span className="sm:block"></span> browse, search and organize your photos and videos with ease, without
|
||||||
sacrificing your privacy.
|
sacrificing your privacy.
|
||||||
@ -36,27 +39,21 @@ function HomepageHeader() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col sm:flex-row place-items-center place-content-center mt-9 gap-4 ">
|
<div className="flex flex-col sm:flex-row place-items-center place-content-center mt-9 gap-4 ">
|
||||||
<Link
|
<Link
|
||||||
className="flex place-items-center place-content-center py-3 px-8 border bg-immich-primary dark:bg-immich-dark-primary rounded-xl no-underline hover:no-underline text-white hover:text-gray-50 dark:text-immich-dark-bg font-bold uppercase"
|
className="flex place-items-center place-content-center py-3 px-8 border bg-immich-primary dark:bg-immich-dark-primary rounded-xl no-underline hover:no-underline text-white hover:text-gray-50 dark:text-immich-dark-bg font-bold"
|
||||||
to="docs/overview/introduction"
|
to="docs/overview/quick-start"
|
||||||
>
|
>
|
||||||
Get started
|
Get Started
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
className="flex place-items-center place-content-center py-3 px-8 border bg-immich-primary/10 dark:bg-gray-300 rounded-xl hover:no-underline text-immich-primary dark:text-immich-dark-bg font-bold uppercase"
|
className="flex place-items-center place-content-center py-3 px-8 border bg-white/90 dark:bg-gray-300 rounded-xl hover:no-underline text-immich-primary dark:text-immich-dark-bg font-bold"
|
||||||
to="https://demo.immich.app/"
|
to="https://demo.immich.app/"
|
||||||
>
|
>
|
||||||
Demo
|
Open Demo
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
className="flex place-items-center place-content-center py-3 px-8 border bg-immich-primary/10 dark:bg-gray-300 rounded-xl hover:no-underline text-immich-primary dark:text-immich-dark-bg font-bold uppercase"
|
|
||||||
to="https://immich.store"
|
|
||||||
>
|
|
||||||
Buy Merch
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="my-12 flex gap-1 font-medium place-items-center place-content-center text-immich-primary dark:text-immich-dark-primary">
|
|
||||||
|
<div className="my-8 flex gap-1 font-medium place-items-center place-content-center text-immich-primary dark:text-immich-dark-primary">
|
||||||
<Icon
|
<Icon
|
||||||
path={discordPath}
|
path={discordPath}
|
||||||
viewBox={discordViewBox} /* viewBox may show an error in your IDE but it is normal. */
|
viewBox={discordViewBox} /* viewBox may show an error in your IDE but it is normal. */
|
||||||
@ -119,7 +116,7 @@ export default function Home(): JSX.Element {
|
|||||||
<HomepageHeader />
|
<HomepageHeader />
|
||||||
<div className="flex flex-col place-items-center text-center place-content-center dark:bg-immich-dark-bg py-8">
|
<div className="flex flex-col place-items-center text-center place-content-center dark:bg-immich-dark-bg py-8">
|
||||||
<p>This project is available under GNU AGPL v3 license.</p>
|
<p>This project is available under GNU AGPL v3 license.</p>
|
||||||
<p className="text-xs">Privacy should not be a luxury</p>
|
<p className="text-sm">Privacy should not be a luxury</p>
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Link from '@docusaurus/Link';
|
|
||||||
import Layout from '@theme/Layout';
|
import Layout from '@theme/Layout';
|
||||||
function HomepageHeader() {
|
function HomepageHeader() {
|
||||||
return (
|
return (
|
||||||
|
1
docs/static/_redirects
vendored
1
docs/static/_redirects
vendored
@ -30,3 +30,4 @@
|
|||||||
/docs/guides/api-album-sync /docs/community-projects 307
|
/docs/guides/api-album-sync /docs/community-projects 307
|
||||||
/docs/guides/remove-offline-files /docs/community-projects 307
|
/docs/guides/remove-offline-files /docs/community-projects 307
|
||||||
/milestones /roadmap 307
|
/milestones /roadmap 307
|
||||||
|
/docs/overview/introduction /docs/overview/welcome 307
|
BIN
docs/static/fonts/overpass/Overpass-Italic.ttf
vendored
Normal file
BIN
docs/static/fonts/overpass/Overpass-Italic.ttf
vendored
Normal file
Binary file not shown.
BIN
docs/static/fonts/overpass/Overpass.ttf
vendored
Normal file
BIN
docs/static/fonts/overpass/Overpass.ttf
vendored
Normal file
Binary file not shown.
BIN
docs/static/fonts/overpass/OverpassMono.ttf
vendored
Normal file
BIN
docs/static/fonts/overpass/OverpassMono.ttf
vendored
Normal file
Binary file not shown.
22
docs/static/img/logomark-dark-with-futo.svg
vendored
Normal file
22
docs/static/img/logomark-dark-with-futo.svg
vendored
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 75 KiB |
21
docs/static/img/logomark-light-with-futo.svg
vendored
Normal file
21
docs/static/img/logomark-light-with-futo.svg
vendored
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 75 KiB |
587
e2e/package-lock.json
generated
587
e2e/package-lock.json
generated
@ -736,9 +736,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@eslint/js": {
|
"node_modules/@eslint/js": {
|
||||||
"version": "9.25.1",
|
"version": "9.26.0",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.25.1.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.26.0.tgz",
|
||||||
"integrity": "sha512-dEIwmjntEx8u3Uvv+kr3PDeeArL8Hw07H9kyYxCjnM9pBjfEhk6uLXSchxxzgiwtRhhzVzqmUSDFBOi1TuZ7qg==",
|
"integrity": "sha512-I9XlJawFdSMvWjDt6wksMCrgns5ggLNfFwFvnShsleWruvXM514Qxk8V246efTw+eo9JABvVz+u3q2RiAowKxQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@ -999,6 +999,28 @@
|
|||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@modelcontextprotocol/sdk": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-9LfmxKTb1v+vUS1/emSk1f5ePmTLkb9Le9AxOB5T0XM59EUumwcS45z05h7aiZx3GI0Bl7mjb3FMEglYj+acuQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"content-type": "^1.0.5",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"cross-spawn": "^7.0.3",
|
||||||
|
"eventsource": "^3.0.2",
|
||||||
|
"express": "^5.0.1",
|
||||||
|
"express-rate-limit": "^7.5.0",
|
||||||
|
"pkce-challenge": "^5.0.0",
|
||||||
|
"raw-body": "^3.0.0",
|
||||||
|
"zod": "^3.23.8",
|
||||||
|
"zod-to-json-schema": "^3.24.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@nodelib/fs.scandir": {
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||||
@ -2207,6 +2229,75 @@
|
|||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/body-parser": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bytes": "^3.1.2",
|
||||||
|
"content-type": "^1.0.5",
|
||||||
|
"debug": "^4.4.0",
|
||||||
|
"http-errors": "^2.0.0",
|
||||||
|
"iconv-lite": "^0.6.3",
|
||||||
|
"on-finished": "^2.4.1",
|
||||||
|
"qs": "^6.14.0",
|
||||||
|
"raw-body": "^3.0.0",
|
||||||
|
"type-is": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/body-parser/node_modules/media-typer": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/body-parser/node_modules/mime-db": {
|
||||||
|
"version": "1.54.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
||||||
|
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/body-parser/node_modules/mime-types": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": "^1.54.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/body-parser/node_modules/type-is": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"content-type": "^1.0.5",
|
||||||
|
"media-typer": "^1.1.0",
|
||||||
|
"mime-types": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.11",
|
"version": "1.1.11",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||||
@ -2596,6 +2687,26 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie": {
|
||||||
|
"version": "0.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||||
|
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cookie-signature": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cookiejar": {
|
"node_modules/cookiejar": {
|
||||||
"version": "2.1.4",
|
"version": "2.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz",
|
||||||
@ -2631,6 +2742,20 @@
|
|||||||
"url": "https://opencollective.com/core-js"
|
"url": "https://opencollective.com/core-js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cors": {
|
||||||
|
"version": "2.8.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
|
||||||
|
"integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"object-assign": "^4",
|
||||||
|
"vary": "^1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@ -3009,9 +3134,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint": {
|
"node_modules/eslint": {
|
||||||
"version": "9.25.1",
|
"version": "9.26.0",
|
||||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.25.1.tgz",
|
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.26.0.tgz",
|
||||||
"integrity": "sha512-E6Mtz9oGQWDCpV12319d59n4tx9zOTXSTmc8BLVxBx+G/0RdM5MvEEJLU9c0+aleoePYYgVTOsRblx433qmhWQ==",
|
"integrity": "sha512-Hx0MOjPh6uK9oq9nVsATZKE/Wlbai7KFjfCuw9UHaguDW3x+HF0O5nIi3ud39TWgrTjTO5nHxmL3R1eANinWHQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -3021,11 +3146,12 @@
|
|||||||
"@eslint/config-helpers": "^0.2.1",
|
"@eslint/config-helpers": "^0.2.1",
|
||||||
"@eslint/core": "^0.13.0",
|
"@eslint/core": "^0.13.0",
|
||||||
"@eslint/eslintrc": "^3.3.1",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
"@eslint/js": "9.25.1",
|
"@eslint/js": "9.26.0",
|
||||||
"@eslint/plugin-kit": "^0.2.8",
|
"@eslint/plugin-kit": "^0.2.8",
|
||||||
"@humanfs/node": "^0.16.6",
|
"@humanfs/node": "^0.16.6",
|
||||||
"@humanwhocodes/module-importer": "^1.0.1",
|
"@humanwhocodes/module-importer": "^1.0.1",
|
||||||
"@humanwhocodes/retry": "^0.4.2",
|
"@humanwhocodes/retry": "^0.4.2",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.8.0",
|
||||||
"@types/estree": "^1.0.6",
|
"@types/estree": "^1.0.6",
|
||||||
"@types/json-schema": "^7.0.15",
|
"@types/json-schema": "^7.0.15",
|
||||||
"ajv": "^6.12.4",
|
"ajv": "^6.12.4",
|
||||||
@ -3049,7 +3175,8 @@
|
|||||||
"lodash.merge": "^4.6.2",
|
"lodash.merge": "^4.6.2",
|
||||||
"minimatch": "^3.1.2",
|
"minimatch": "^3.1.2",
|
||||||
"natural-compare": "^1.4.0",
|
"natural-compare": "^1.4.0",
|
||||||
"optionator": "^0.9.3"
|
"optionator": "^0.9.3",
|
||||||
|
"zod": "^3.24.2"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"eslint": "bin/eslint.js"
|
"eslint": "bin/eslint.js"
|
||||||
@ -3083,9 +3210,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint-plugin-prettier": {
|
"node_modules/eslint-plugin-prettier": {
|
||||||
"version": "5.2.6",
|
"version": "5.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.3.1.tgz",
|
||||||
"integrity": "sha512-mUcf7QG2Tjk7H055Jk0lGBjbgDnfrvqjhXh9t2xLMSCjZVcw9Rb1V6sVNXO0th3jgeO7zllWPTNRil3JW94TnQ==",
|
"integrity": "sha512-vad9VWgEm9xaVXRNmb4aeOt0PWDc61IAdzghkbYQ2wavgax148iKoX1rNJcgkBGCipzLzOnHYVgL7xudM9yccQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -3277,6 +3404,39 @@
|
|||||||
"url": "https://github.com/eta-dev/eta?sponsor=1"
|
"url": "https://github.com/eta-dev/eta?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/etag": {
|
||||||
|
"version": "1.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||||
|
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/eventsource": {
|
||||||
|
"version": "3.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
|
||||||
|
"integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"eventsource-parser": "^3.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/eventsource-parser": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-VARTJ9CYeuQYb0pZEPbzi740OWFgpHe7AYJ2WFZVnUDUQp5Dk2yJUgF36YsZ81cOyxT0QxmXD2EQpapAouzWVA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/exiftool-vendored": {
|
"node_modules/exiftool-vendored": {
|
||||||
"version": "28.8.0",
|
"version": "28.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.8.0.tgz",
|
||||||
@ -3327,6 +3487,170 @@
|
|||||||
"node": ">=12.0.0"
|
"node": ">=12.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/express": {
|
||||||
|
"version": "5.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
|
||||||
|
"integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"accepts": "^2.0.0",
|
||||||
|
"body-parser": "^2.2.0",
|
||||||
|
"content-disposition": "^1.0.0",
|
||||||
|
"content-type": "^1.0.5",
|
||||||
|
"cookie": "^0.7.1",
|
||||||
|
"cookie-signature": "^1.2.1",
|
||||||
|
"debug": "^4.4.0",
|
||||||
|
"encodeurl": "^2.0.0",
|
||||||
|
"escape-html": "^1.0.3",
|
||||||
|
"etag": "^1.8.1",
|
||||||
|
"finalhandler": "^2.1.0",
|
||||||
|
"fresh": "^2.0.0",
|
||||||
|
"http-errors": "^2.0.0",
|
||||||
|
"merge-descriptors": "^2.0.0",
|
||||||
|
"mime-types": "^3.0.0",
|
||||||
|
"on-finished": "^2.4.1",
|
||||||
|
"once": "^1.4.0",
|
||||||
|
"parseurl": "^1.3.3",
|
||||||
|
"proxy-addr": "^2.0.7",
|
||||||
|
"qs": "^6.14.0",
|
||||||
|
"range-parser": "^1.2.1",
|
||||||
|
"router": "^2.2.0",
|
||||||
|
"send": "^1.1.0",
|
||||||
|
"serve-static": "^2.2.0",
|
||||||
|
"statuses": "^2.0.1",
|
||||||
|
"type-is": "^2.0.1",
|
||||||
|
"vary": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/express-rate-limit": {
|
||||||
|
"version": "7.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.0.tgz",
|
||||||
|
"integrity": "sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/express-rate-limit"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"express": "^4.11 || 5 || ^5.0.0-beta.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/express/node_modules/accepts": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-types": "^3.0.0",
|
||||||
|
"negotiator": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/express/node_modules/content-disposition": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "5.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/express/node_modules/encodeurl": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/express/node_modules/fresh": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/express/node_modules/media-typer": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/express/node_modules/mime-db": {
|
||||||
|
"version": "1.54.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
||||||
|
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/express/node_modules/mime-types": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": "^1.54.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/express/node_modules/negotiator": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/express/node_modules/type-is": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"content-type": "^1.0.5",
|
||||||
|
"media-typer": "^1.1.0",
|
||||||
|
"mime-types": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
@ -3428,6 +3752,34 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/finalhandler": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "^4.4.0",
|
||||||
|
"encodeurl": "^2.0.0",
|
||||||
|
"escape-html": "^1.0.3",
|
||||||
|
"on-finished": "^2.4.1",
|
||||||
|
"parseurl": "^1.3.3",
|
||||||
|
"statuses": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/finalhandler/node_modules/encodeurl": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/find-up": {
|
"node_modules/find-up": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
|
||||||
@ -3537,6 +3889,16 @@
|
|||||||
"url": "https://ko-fi.com/tunnckoCore/commissions"
|
"url": "https://ko-fi.com/tunnckoCore/commissions"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/forwarded": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fresh": {
|
"node_modules/fresh": {
|
||||||
"version": "0.5.2",
|
"version": "0.5.2",
|
||||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||||
@ -4160,6 +4522,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/ipaddr.js": {
|
||||||
|
"version": "1.9.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
|
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-builtin-module": {
|
"node_modules/is-builtin-module": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-4.0.0.tgz",
|
||||||
@ -4238,6 +4610,13 @@
|
|||||||
"node": ">=0.12.0"
|
"node": ">=0.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-promise": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/is-regex": {
|
"node_modules/is-regex": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
|
||||||
@ -4646,6 +5025,19 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/merge-descriptors": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/merge2": {
|
"node_modules/merge2": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||||
@ -5410,6 +5802,16 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pkce-challenge": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.52.0",
|
"version": "1.52.0",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz",
|
||||||
@ -5616,6 +6018,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/proxy-addr": {
|
||||||
|
"version": "2.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||||
|
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"forwarded": "0.2.0",
|
||||||
|
"ipaddr.js": "1.9.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/punycode": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
@ -5676,6 +6092,16 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/range-parser": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/raw-body": {
|
"node_modules/raw-body": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz",
|
||||||
@ -5904,6 +6330,33 @@
|
|||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/router": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "^4.4.0",
|
||||||
|
"depd": "^2.0.0",
|
||||||
|
"is-promise": "^4.0.0",
|
||||||
|
"parseurl": "^1.3.3",
|
||||||
|
"path-to-regexp": "^8.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/router/node_modules/path-to-regexp": {
|
||||||
|
"version": "8.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz",
|
||||||
|
"integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/run-parallel": {
|
"node_modules/run-parallel": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||||
@ -5987,6 +6440,98 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/send": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "^4.3.5",
|
||||||
|
"encodeurl": "^2.0.0",
|
||||||
|
"escape-html": "^1.0.3",
|
||||||
|
"etag": "^1.8.1",
|
||||||
|
"fresh": "^2.0.0",
|
||||||
|
"http-errors": "^2.0.0",
|
||||||
|
"mime-types": "^3.0.1",
|
||||||
|
"ms": "^2.1.3",
|
||||||
|
"on-finished": "^2.4.1",
|
||||||
|
"range-parser": "^1.2.1",
|
||||||
|
"statuses": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/send/node_modules/encodeurl": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/send/node_modules/fresh": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/send/node_modules/mime-db": {
|
||||||
|
"version": "1.54.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
||||||
|
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/send/node_modules/mime-types": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": "^1.54.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/serve-static": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"encodeurl": "^2.0.0",
|
||||||
|
"escape-html": "^1.0.3",
|
||||||
|
"parseurl": "^1.3.3",
|
||||||
|
"send": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/serve-static/node_modules/encodeurl": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/set-blocking": {
|
"node_modules/set-blocking": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||||
@ -7383,6 +7928,26 @@
|
|||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zod": {
|
||||||
|
"version": "3.24.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.4.tgz",
|
||||||
|
"integrity": "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zod-to-json-schema": {
|
||||||
|
"version": "3.24.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz",
|
||||||
|
"integrity": "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^3.24.1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
14
i18n/en.json
14
i18n/en.json
@ -1,4 +1,16 @@
|
|||||||
{
|
{
|
||||||
|
"user_pin_code_settings": "PIN Code",
|
||||||
|
"user_pin_code_settings_description": "Manage your PIN code",
|
||||||
|
"current_pin_code": "Current PIN code",
|
||||||
|
"new_pin_code": "New PIN code",
|
||||||
|
"setup_pin_code": "Setup a PIN code",
|
||||||
|
"confirm_new_pin_code": "Confirm new PIN code",
|
||||||
|
"unable_to_change_pin_code": "Unable to change PIN code",
|
||||||
|
"unable_to_setup_pin_code": "Unable to setup PIN code",
|
||||||
|
"pin_code_changed_successfully": "Successfully changed PIN code",
|
||||||
|
"pin_code_setup_successfully": "Successfully setup a PIN code",
|
||||||
|
"pin_code_reset_successfully": "Successfully reset PIN code",
|
||||||
|
"reset_pin_code": "Reset PIN code",
|
||||||
"about": "About",
|
"about": "About",
|
||||||
"account": "Account",
|
"account": "Account",
|
||||||
"account_settings": "Account Settings",
|
"account_settings": "Account Settings",
|
||||||
@ -53,6 +65,7 @@
|
|||||||
"confirm_email_below": "To confirm, type \"{email}\" below",
|
"confirm_email_below": "To confirm, type \"{email}\" below",
|
||||||
"confirm_reprocess_all_faces": "Are you sure you want to reprocess all faces? This will also clear named people.",
|
"confirm_reprocess_all_faces": "Are you sure you want to reprocess all faces? This will also clear named people.",
|
||||||
"confirm_user_password_reset": "Are you sure you want to reset {user}'s password?",
|
"confirm_user_password_reset": "Are you sure you want to reset {user}'s password?",
|
||||||
|
"confirm_user_pin_code_reset": "Are you sure you want to reset {user}'s PIN code?",
|
||||||
"create_job": "Create job",
|
"create_job": "Create job",
|
||||||
"cron_expression": "Cron expression",
|
"cron_expression": "Cron expression",
|
||||||
"cron_expression_description": "Set the scanning interval using the cron format. For more information please refer to e.g. <link>Crontab Guru</link>",
|
"cron_expression_description": "Set the scanning interval using the cron format. For more information please refer to e.g. <link>Crontab Guru</link>",
|
||||||
@ -922,6 +935,7 @@
|
|||||||
"unable_to_remove_reaction": "Unable to remove reaction",
|
"unable_to_remove_reaction": "Unable to remove reaction",
|
||||||
"unable_to_repair_items": "Unable to repair items",
|
"unable_to_repair_items": "Unable to repair items",
|
||||||
"unable_to_reset_password": "Unable to reset password",
|
"unable_to_reset_password": "Unable to reset password",
|
||||||
|
"unable_to_reset_pin_code": "Unable to reset PIN code",
|
||||||
"unable_to_resolve_duplicate": "Unable to resolve duplicate",
|
"unable_to_resolve_duplicate": "Unable to resolve duplicate",
|
||||||
"unable_to_restore_assets": "Unable to restore assets",
|
"unable_to_restore_assets": "Unable to restore assets",
|
||||||
"unable_to_restore_trash": "Unable to restore trash",
|
"unable_to_restore_trash": "Unable to restore trash",
|
||||||
|
@ -53,7 +53,7 @@ def init_rknn(model_path: str) -> "RKNNLite":
|
|||||||
ret = rknn_lite.init_runtime() # Please do not set this parameter on other platforms.
|
ret = rknn_lite.init_runtime() # Please do not set this parameter on other platforms.
|
||||||
|
|
||||||
if ret != 0:
|
if ret != 0:
|
||||||
raise RuntimeError("Failed to inititalize RKNN runtime environment")
|
raise RuntimeError("Failed to initialize RKNN runtime environment")
|
||||||
|
|
||||||
return rknn_lite
|
return rknn_lite
|
||||||
|
|
||||||
|
@ -0,0 +1,146 @@
|
|||||||
|
package app.alextran.immich
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
|
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||||
|
import io.flutter.plugin.common.BinaryMessenger
|
||||||
|
import io.flutter.plugin.common.MethodCall
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.net.Socket
|
||||||
|
import java.security.KeyStore
|
||||||
|
import java.security.cert.X509Certificate
|
||||||
|
import javax.net.ssl.HostnameVerifier
|
||||||
|
import javax.net.ssl.HttpsURLConnection
|
||||||
|
import javax.net.ssl.KeyManager
|
||||||
|
import javax.net.ssl.KeyManagerFactory
|
||||||
|
import javax.net.ssl.SSLContext
|
||||||
|
import javax.net.ssl.SSLEngine
|
||||||
|
import javax.net.ssl.SSLSession
|
||||||
|
import javax.net.ssl.TrustManager
|
||||||
|
import javax.net.ssl.TrustManagerFactory
|
||||||
|
import javax.net.ssl.X509ExtendedTrustManager
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Android plugin for Dart `HttpSSLOptions`
|
||||||
|
*/
|
||||||
|
class HttpSSLOptionsPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||||
|
private var methodChannel: MethodChannel? = null
|
||||||
|
|
||||||
|
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
|
onAttachedToEngine(binding.applicationContext, binding.binaryMessenger)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onAttachedToEngine(ctx: Context, messenger: BinaryMessenger) {
|
||||||
|
methodChannel = MethodChannel(messenger, "immich/httpSSLOptions")
|
||||||
|
methodChannel?.setMethodCallHandler(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
|
onDetachedFromEngine()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onDetachedFromEngine() {
|
||||||
|
methodChannel?.setMethodCallHandler(null)
|
||||||
|
methodChannel = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
try {
|
||||||
|
when (call.method) {
|
||||||
|
"apply" -> {
|
||||||
|
val args = call.arguments<ArrayList<*>>()!!
|
||||||
|
|
||||||
|
var tm: Array<TrustManager>? = null
|
||||||
|
if (args[0] as Boolean) {
|
||||||
|
tm = arrayOf(AllowSelfSignedTrustManager(args[1] as? String))
|
||||||
|
}
|
||||||
|
|
||||||
|
var km: Array<KeyManager>? = null
|
||||||
|
if (args[2] != null) {
|
||||||
|
val cert = ByteArrayInputStream(args[2] as ByteArray)
|
||||||
|
val password = (args[3] as String).toCharArray()
|
||||||
|
val keyStore = KeyStore.getInstance("PKCS12")
|
||||||
|
keyStore.load(cert, password)
|
||||||
|
val keyManagerFactory =
|
||||||
|
KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm())
|
||||||
|
keyManagerFactory.init(keyStore, null)
|
||||||
|
km = keyManagerFactory.keyManagers
|
||||||
|
}
|
||||||
|
|
||||||
|
val sslContext = SSLContext.getInstance("TLS")
|
||||||
|
sslContext.init(km, tm, null)
|
||||||
|
HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory)
|
||||||
|
|
||||||
|
HttpsURLConnection.setDefaultHostnameVerifier(AllowSelfSignedHostnameVerifier(args[1] as? String))
|
||||||
|
|
||||||
|
result.success(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> result.notImplemented()
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
result.error("error", e.message, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("CustomX509TrustManager")
|
||||||
|
class AllowSelfSignedTrustManager(private val serverHost: String?) : X509ExtendedTrustManager() {
|
||||||
|
private val defaultTrustManager: X509ExtendedTrustManager = getDefaultTrustManager()
|
||||||
|
|
||||||
|
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) =
|
||||||
|
defaultTrustManager.checkClientTrusted(chain, authType)
|
||||||
|
|
||||||
|
override fun checkClientTrusted(
|
||||||
|
chain: Array<out X509Certificate>?, authType: String?, socket: Socket?
|
||||||
|
) = defaultTrustManager.checkClientTrusted(chain, authType, socket)
|
||||||
|
|
||||||
|
override fun checkClientTrusted(
|
||||||
|
chain: Array<out X509Certificate>?, authType: String?, engine: SSLEngine?
|
||||||
|
) = defaultTrustManager.checkClientTrusted(chain, authType, engine)
|
||||||
|
|
||||||
|
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {
|
||||||
|
if (serverHost == null) return
|
||||||
|
defaultTrustManager.checkServerTrusted(chain, authType)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun checkServerTrusted(
|
||||||
|
chain: Array<out X509Certificate>?, authType: String?, socket: Socket?
|
||||||
|
) {
|
||||||
|
if (serverHost == null) return
|
||||||
|
val socketAddress = socket?.remoteSocketAddress
|
||||||
|
if (socketAddress is InetSocketAddress && socketAddress.hostName == serverHost) return
|
||||||
|
defaultTrustManager.checkServerTrusted(chain, authType, socket)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun checkServerTrusted(
|
||||||
|
chain: Array<out X509Certificate>?, authType: String?, engine: SSLEngine?
|
||||||
|
) {
|
||||||
|
if (serverHost == null || engine?.peerHost == serverHost) return
|
||||||
|
defaultTrustManager.checkServerTrusted(chain, authType, engine)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAcceptedIssuers(): Array<X509Certificate> = defaultTrustManager.acceptedIssuers
|
||||||
|
|
||||||
|
private fun getDefaultTrustManager(): X509ExtendedTrustManager {
|
||||||
|
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||||
|
factory.init(null as KeyStore?)
|
||||||
|
return factory.trustManagers.filterIsInstance<X509ExtendedTrustManager>().first()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AllowSelfSignedHostnameVerifier(private val serverHost: String?) : HostnameVerifier {
|
||||||
|
companion object {
|
||||||
|
private val _defaultHostnameVerifier = HttpsURLConnection.getDefaultHostnameVerifier()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun verify(hostname: String?, session: SSLSession?): Boolean {
|
||||||
|
if (serverHost == null || hostname == serverHost) {
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
return _defaultHostnameVerifier.verify(hostname, session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -8,6 +8,7 @@ class MainActivity : FlutterActivity() {
|
|||||||
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
|
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
|
||||||
super.configureFlutterEngine(flutterEngine)
|
super.configureFlutterEngine(flutterEngine)
|
||||||
flutterEngine.plugins.add(BackgroundServicePlugin())
|
flutterEngine.plugins.add(BackgroundServicePlugin())
|
||||||
|
flutterEngine.plugins.add(HttpSSLOptionsPlugin())
|
||||||
// No need to set up method channel here as it's now handled in the plugin
|
// No need to set up method channel here as it's now handled in the plugin
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -6,8 +6,8 @@ const Map<String, Locale> locales = {
|
|||||||
// Additional locales
|
// Additional locales
|
||||||
'Arabic (ar)': Locale('ar'),
|
'Arabic (ar)': Locale('ar'),
|
||||||
'Catalan (ca)': Locale('ca'),
|
'Catalan (ca)': Locale('ca'),
|
||||||
'Chinese Simplified (zh_CN)': Locale('zh', 'SIMPLIFIED'),
|
'Chinese Simplified (zh_CN)': Locale('zh', 'CN'),
|
||||||
'Chinese Traditional (zh_TW)': Locale('zh', 'Hant'),
|
'Chinese Traditional (zh_TW)': Locale('zh', 'TW'),
|
||||||
'Czech (cs)': Locale('cs'),
|
'Czech (cs)': Locale('cs'),
|
||||||
'Danish (da)': Locale('da'),
|
'Danish (da)': Locale('da'),
|
||||||
'Dutch (nl)': Locale('nl'),
|
'Dutch (nl)': Locale('nl'),
|
||||||
@ -31,8 +31,10 @@ const Map<String, Locale> locales = {
|
|||||||
'Portuguese (pt)': Locale('pt'),
|
'Portuguese (pt)': Locale('pt'),
|
||||||
'Romanian (ro)': Locale('ro'),
|
'Romanian (ro)': Locale('ro'),
|
||||||
'Russian (ru)': Locale('ru'),
|
'Russian (ru)': Locale('ru'),
|
||||||
'Serbian Cyrillic (sr_Cyrl)': Locale('sr', 'Cyrl'),
|
'Serbian Cyrillic (sr_Cyrl)':
|
||||||
'Serbian Latin (sr_Latn)': Locale('sr', 'Latn'),
|
Locale.fromSubtags(languageCode: 'sr', scriptCode: 'Cyrl'),
|
||||||
|
'Serbian Latin (sr_Latn)':
|
||||||
|
Locale.fromSubtags(languageCode: 'sr', scriptCode: 'Latn'),
|
||||||
'Slovak (sk)': Locale('sk'),
|
'Slovak (sk)': Locale('sk'),
|
||||||
'Slovenian (sl)': Locale('sl'),
|
'Slovenian (sl)': Locale('sl'),
|
||||||
'Spanish (es)': Locale('es'),
|
'Spanish (es)': Locale('es'),
|
||||||
|
@ -27,7 +27,7 @@ import 'package:immich_mobile/theme/theme_data.dart';
|
|||||||
import 'package:immich_mobile/utils/bootstrap.dart';
|
import 'package:immich_mobile/utils/bootstrap.dart';
|
||||||
import 'package:immich_mobile/utils/cache/widgets_binding.dart';
|
import 'package:immich_mobile/utils/cache/widgets_binding.dart';
|
||||||
import 'package:immich_mobile/utils/download.dart';
|
import 'package:immich_mobile/utils/download.dart';
|
||||||
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
|
import 'package:immich_mobile/utils/http_ssl_options.dart';
|
||||||
import 'package:immich_mobile/utils/migration.dart';
|
import 'package:immich_mobile/utils/migration.dart';
|
||||||
import 'package:intl/date_symbol_data_local.dart';
|
import 'package:intl/date_symbol_data_local.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
@ -42,7 +42,7 @@ void main() async {
|
|||||||
// Warm-up isolate pool for worker manager
|
// Warm-up isolate pool for worker manager
|
||||||
await workerManager.init(dynamicSpawning: true);
|
await workerManager.init(dynamicSpawning: true);
|
||||||
await migrateDatabaseIfNeeded(db);
|
await migrateDatabaseIfNeeded(db);
|
||||||
HttpOverrides.global = HttpSSLCertOverride();
|
HttpSSLOptions.apply();
|
||||||
|
|
||||||
runApp(
|
runApp(
|
||||||
ProviderScope(
|
ProviderScope(
|
||||||
|
@ -32,7 +32,7 @@ import 'package:immich_mobile/services/localization.service.dart';
|
|||||||
import 'package:immich_mobile/utils/backup_progress.dart';
|
import 'package:immich_mobile/utils/backup_progress.dart';
|
||||||
import 'package:immich_mobile/utils/bootstrap.dart';
|
import 'package:immich_mobile/utils/bootstrap.dart';
|
||||||
import 'package:immich_mobile/utils/diff.dart';
|
import 'package:immich_mobile/utils/diff.dart';
|
||||||
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
|
import 'package:immich_mobile/utils/http_ssl_options.dart';
|
||||||
import 'package:path_provider_foundation/path_provider_foundation.dart';
|
import 'package:path_provider_foundation/path_provider_foundation.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
|
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
|
||||||
|
|
||||||
@ -359,7 +359,7 @@ class BackgroundService {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
HttpOverrides.global = HttpSSLCertOverride();
|
HttpSSLOptions.apply();
|
||||||
ref
|
ref
|
||||||
.read(apiServiceProvider)
|
.read(apiServiceProvider)
|
||||||
.setAccessToken(Store.get(StoreKey.accessToken));
|
.setAccessToken(Store.get(StoreKey.accessToken));
|
||||||
|
@ -1,16 +1,20 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
class HttpSSLCertOverride extends HttpOverrides {
|
class HttpSSLCertOverride extends HttpOverrides {
|
||||||
static final Logger _log = Logger("HttpSSLCertOverride");
|
static final Logger _log = Logger("HttpSSLCertOverride");
|
||||||
|
final bool _allowSelfSignedSSLCert;
|
||||||
|
final String? _serverHost;
|
||||||
final SSLClientCertStoreVal? _clientCert;
|
final SSLClientCertStoreVal? _clientCert;
|
||||||
late final SecurityContext? _ctxWithCert;
|
late final SecurityContext? _ctxWithCert;
|
||||||
|
|
||||||
HttpSSLCertOverride() : _clientCert = SSLClientCertStoreVal.load() {
|
HttpSSLCertOverride(
|
||||||
|
this._allowSelfSignedSSLCert,
|
||||||
|
this._serverHost,
|
||||||
|
this._clientCert,
|
||||||
|
) {
|
||||||
if (_clientCert != null) {
|
if (_clientCert != null) {
|
||||||
_ctxWithCert = SecurityContext(withTrustedRoots: true);
|
_ctxWithCert = SecurityContext(withTrustedRoots: true);
|
||||||
if (_ctxWithCert != null) {
|
if (_ctxWithCert != null) {
|
||||||
@ -47,28 +51,15 @@ class HttpSSLCertOverride extends HttpOverrides {
|
|||||||
|
|
||||||
return super.createHttpClient(context)
|
return super.createHttpClient(context)
|
||||||
..badCertificateCallback = (X509Certificate cert, String host, int port) {
|
..badCertificateCallback = (X509Certificate cert, String host, int port) {
|
||||||
AppSettingsEnum setting = AppSettingsEnum.allowSelfSignedSSLCert;
|
if (_allowSelfSignedSSLCert) {
|
||||||
|
|
||||||
// Check if user has allowed self signed SSL certificates.
|
|
||||||
bool selfSignedCertsAllowed =
|
|
||||||
Store.get(setting.storeKey as StoreKey<bool>, setting.defaultValue);
|
|
||||||
|
|
||||||
bool isLoggedIn = Store.tryGet(StoreKey.currentUser) != null;
|
|
||||||
|
|
||||||
// Conduct server host checks if user is logged in to avoid making
|
// Conduct server host checks if user is logged in to avoid making
|
||||||
// insecure SSL connections to services that are not the immich server.
|
// insecure SSL connections to services that are not the immich server.
|
||||||
if (isLoggedIn && selfSignedCertsAllowed) {
|
if (_serverHost == null || _serverHost.contains(host)) {
|
||||||
String serverHost =
|
return true;
|
||||||
Uri.parse(Store.tryGet(StoreKey.serverEndpoint) ?? "").host;
|
}
|
||||||
|
|
||||||
selfSignedCertsAllowed &= serverHost.contains(host);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!selfSignedCertsAllowed) {
|
|
||||||
_log.severe("Invalid SSL certificate for $host:$port");
|
_log.severe("Invalid SSL certificate for $host:$port");
|
||||||
}
|
return false;
|
||||||
|
|
||||||
return selfSignedCertsAllowed;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
47
mobile/lib/utils/http_ssl_options.dart
Normal file
47
mobile/lib/utils/http_ssl_options.dart
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||||
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
|
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
|
class HttpSSLOptions {
|
||||||
|
static const MethodChannel _channel = MethodChannel('immich/httpSSLOptions');
|
||||||
|
|
||||||
|
static void apply() {
|
||||||
|
AppSettingsEnum setting = AppSettingsEnum.allowSelfSignedSSLCert;
|
||||||
|
bool allowSelfSignedSSLCert =
|
||||||
|
Store.get(setting.storeKey as StoreKey<bool>, setting.defaultValue);
|
||||||
|
_apply(allowSelfSignedSSLCert);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void applyFromSettings(bool newValue) {
|
||||||
|
_apply(newValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _apply(bool allowSelfSignedSSLCert) {
|
||||||
|
String? serverHost;
|
||||||
|
if (allowSelfSignedSSLCert && Store.tryGet(StoreKey.currentUser) != null) {
|
||||||
|
serverHost = Uri.parse(Store.tryGet(StoreKey.serverEndpoint) ?? "").host;
|
||||||
|
}
|
||||||
|
|
||||||
|
SSLClientCertStoreVal? clientCert = SSLClientCertStoreVal.load();
|
||||||
|
|
||||||
|
HttpOverrides.global =
|
||||||
|
HttpSSLCertOverride(allowSelfSignedSSLCert, serverHost, clientCert);
|
||||||
|
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
_channel.invokeMethod("apply", [
|
||||||
|
allowSelfSignedSSLCert,
|
||||||
|
serverHost,
|
||||||
|
clientCert?.data,
|
||||||
|
clientCert?.password,
|
||||||
|
]).onError<PlatformException>((e, _) {
|
||||||
|
final log = Logger("HttpSSLOptions");
|
||||||
|
log.severe('Failed to set SSL options', e.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -10,7 +10,7 @@ import 'package:immich_mobile/providers/user.provider.dart';
|
|||||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
|
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
|
||||||
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
|
import 'package:immich_mobile/utils/http_ssl_options.dart';
|
||||||
import 'package:immich_mobile/widgets/settings/custom_proxy_headers_settings/custome_proxy_headers_settings.dart';
|
import 'package:immich_mobile/widgets/settings/custom_proxy_headers_settings/custome_proxy_headers_settings.dart';
|
||||||
import 'package:immich_mobile/widgets/settings/local_storage_settings.dart';
|
import 'package:immich_mobile/widgets/settings/local_storage_settings.dart';
|
||||||
import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart';
|
import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart';
|
||||||
@ -104,7 +104,7 @@ class AdvancedSettings extends HookConsumerWidget {
|
|||||||
valueNotifier: allowSelfSignedSSLCert,
|
valueNotifier: allowSelfSignedSSLCert,
|
||||||
title: "advanced_settings_self_signed_ssl_title".tr(),
|
title: "advanced_settings_self_signed_ssl_title".tr(),
|
||||||
subtitle: "advanced_settings_self_signed_ssl_subtitle".tr(),
|
subtitle: "advanced_settings_self_signed_ssl_subtitle".tr(),
|
||||||
onChanged: (_) => HttpOverrides.global = HttpSSLCertOverride(),
|
onChanged: HttpSSLOptions.applyFromSettings,
|
||||||
),
|
),
|
||||||
const CustomeProxyHeaderSettings(),
|
const CustomeProxyHeaderSettings(),
|
||||||
SslClientCertSettings(isLoggedIn: ref.read(currentUserProvider) != null),
|
SslClientCertSettings(isLoggedIn: ref.read(currentUserProvider) != null),
|
||||||
|
@ -8,6 +8,7 @@ import 'package:immich_mobile/entities/store.entity.dart';
|
|||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||||
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
|
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
|
||||||
|
import 'package:immich_mobile/utils/http_ssl_options.dart';
|
||||||
|
|
||||||
class SslClientCertSettings extends StatefulWidget {
|
class SslClientCertSettings extends StatefulWidget {
|
||||||
const SslClientCertSettings({super.key, required this.isLoggedIn});
|
const SslClientCertSettings({super.key, required this.isLoggedIn});
|
||||||
@ -103,7 +104,7 @@ class _SslClientCertSettingsState extends State<SslClientCertSettings> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
cert.save();
|
cert.save();
|
||||||
HttpOverrides.global = HttpSSLCertOverride();
|
HttpSSLOptions.apply();
|
||||||
setState(
|
setState(
|
||||||
() => isCertExist = true,
|
() => isCertExist = true,
|
||||||
);
|
);
|
||||||
@ -152,7 +153,7 @@ class _SslClientCertSettingsState extends State<SslClientCertSettings> {
|
|||||||
|
|
||||||
void removeCert(BuildContext context) {
|
void removeCert(BuildContext context) {
|
||||||
SSLClientCertStoreVal.delete();
|
SSLClientCertStoreVal.delete();
|
||||||
HttpOverrides.global = HttpSSLCertOverride();
|
HttpSSLOptions.apply();
|
||||||
setState(
|
setState(
|
||||||
() => isCertExist = false,
|
() => isCertExist = false,
|
||||||
);
|
);
|
||||||
|
7
mobile/openapi/README.md
generated
7
mobile/openapi/README.md
generated
@ -109,8 +109,12 @@ Class | Method | HTTP request | Description
|
|||||||
*AssetsApi* | [**uploadAsset**](doc//AssetsApi.md#uploadasset) | **POST** /assets |
|
*AssetsApi* | [**uploadAsset**](doc//AssetsApi.md#uploadasset) | **POST** /assets |
|
||||||
*AssetsApi* | [**viewAsset**](doc//AssetsApi.md#viewasset) | **GET** /assets/{id}/thumbnail |
|
*AssetsApi* | [**viewAsset**](doc//AssetsApi.md#viewasset) | **GET** /assets/{id}/thumbnail |
|
||||||
*AuthenticationApi* | [**changePassword**](doc//AuthenticationApi.md#changepassword) | **POST** /auth/change-password |
|
*AuthenticationApi* | [**changePassword**](doc//AuthenticationApi.md#changepassword) | **POST** /auth/change-password |
|
||||||
|
*AuthenticationApi* | [**changePinCode**](doc//AuthenticationApi.md#changepincode) | **PUT** /auth/pin-code |
|
||||||
|
*AuthenticationApi* | [**getAuthStatus**](doc//AuthenticationApi.md#getauthstatus) | **GET** /auth/status |
|
||||||
*AuthenticationApi* | [**login**](doc//AuthenticationApi.md#login) | **POST** /auth/login |
|
*AuthenticationApi* | [**login**](doc//AuthenticationApi.md#login) | **POST** /auth/login |
|
||||||
*AuthenticationApi* | [**logout**](doc//AuthenticationApi.md#logout) | **POST** /auth/logout |
|
*AuthenticationApi* | [**logout**](doc//AuthenticationApi.md#logout) | **POST** /auth/logout |
|
||||||
|
*AuthenticationApi* | [**resetPinCode**](doc//AuthenticationApi.md#resetpincode) | **DELETE** /auth/pin-code |
|
||||||
|
*AuthenticationApi* | [**setupPinCode**](doc//AuthenticationApi.md#setuppincode) | **POST** /auth/pin-code |
|
||||||
*AuthenticationApi* | [**signUpAdmin**](doc//AuthenticationApi.md#signupadmin) | **POST** /auth/admin-sign-up |
|
*AuthenticationApi* | [**signUpAdmin**](doc//AuthenticationApi.md#signupadmin) | **POST** /auth/admin-sign-up |
|
||||||
*AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken |
|
*AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken |
|
||||||
*DeprecatedApi* | [**getRandom**](doc//DeprecatedApi.md#getrandom) | **GET** /assets/random |
|
*DeprecatedApi* | [**getRandom**](doc//DeprecatedApi.md#getrandom) | **GET** /assets/random |
|
||||||
@ -304,6 +308,7 @@ Class | Method | HTTP request | Description
|
|||||||
- [AssetTypeEnum](doc//AssetTypeEnum.md)
|
- [AssetTypeEnum](doc//AssetTypeEnum.md)
|
||||||
- [AssetVisibility](doc//AssetVisibility.md)
|
- [AssetVisibility](doc//AssetVisibility.md)
|
||||||
- [AudioCodec](doc//AudioCodec.md)
|
- [AudioCodec](doc//AudioCodec.md)
|
||||||
|
- [AuthStatusResponseDto](doc//AuthStatusResponseDto.md)
|
||||||
- [AvatarUpdate](doc//AvatarUpdate.md)
|
- [AvatarUpdate](doc//AvatarUpdate.md)
|
||||||
- [BulkIdResponseDto](doc//BulkIdResponseDto.md)
|
- [BulkIdResponseDto](doc//BulkIdResponseDto.md)
|
||||||
- [BulkIdsDto](doc//BulkIdsDto.md)
|
- [BulkIdsDto](doc//BulkIdsDto.md)
|
||||||
@ -383,6 +388,8 @@ Class | Method | HTTP request | Description
|
|||||||
- [PersonStatisticsResponseDto](doc//PersonStatisticsResponseDto.md)
|
- [PersonStatisticsResponseDto](doc//PersonStatisticsResponseDto.md)
|
||||||
- [PersonUpdateDto](doc//PersonUpdateDto.md)
|
- [PersonUpdateDto](doc//PersonUpdateDto.md)
|
||||||
- [PersonWithFacesResponseDto](doc//PersonWithFacesResponseDto.md)
|
- [PersonWithFacesResponseDto](doc//PersonWithFacesResponseDto.md)
|
||||||
|
- [PinCodeChangeDto](doc//PinCodeChangeDto.md)
|
||||||
|
- [PinCodeSetupDto](doc//PinCodeSetupDto.md)
|
||||||
- [PlacesResponseDto](doc//PlacesResponseDto.md)
|
- [PlacesResponseDto](doc//PlacesResponseDto.md)
|
||||||
- [PurchaseResponse](doc//PurchaseResponse.md)
|
- [PurchaseResponse](doc//PurchaseResponse.md)
|
||||||
- [PurchaseUpdate](doc//PurchaseUpdate.md)
|
- [PurchaseUpdate](doc//PurchaseUpdate.md)
|
||||||
|
3
mobile/openapi/lib/api.dart
generated
3
mobile/openapi/lib/api.dart
generated
@ -108,6 +108,7 @@ part 'model/asset_stats_response_dto.dart';
|
|||||||
part 'model/asset_type_enum.dart';
|
part 'model/asset_type_enum.dart';
|
||||||
part 'model/asset_visibility.dart';
|
part 'model/asset_visibility.dart';
|
||||||
part 'model/audio_codec.dart';
|
part 'model/audio_codec.dart';
|
||||||
|
part 'model/auth_status_response_dto.dart';
|
||||||
part 'model/avatar_update.dart';
|
part 'model/avatar_update.dart';
|
||||||
part 'model/bulk_id_response_dto.dart';
|
part 'model/bulk_id_response_dto.dart';
|
||||||
part 'model/bulk_ids_dto.dart';
|
part 'model/bulk_ids_dto.dart';
|
||||||
@ -187,6 +188,8 @@ part 'model/person_response_dto.dart';
|
|||||||
part 'model/person_statistics_response_dto.dart';
|
part 'model/person_statistics_response_dto.dart';
|
||||||
part 'model/person_update_dto.dart';
|
part 'model/person_update_dto.dart';
|
||||||
part 'model/person_with_faces_response_dto.dart';
|
part 'model/person_with_faces_response_dto.dart';
|
||||||
|
part 'model/pin_code_change_dto.dart';
|
||||||
|
part 'model/pin_code_setup_dto.dart';
|
||||||
part 'model/places_response_dto.dart';
|
part 'model/places_response_dto.dart';
|
||||||
part 'model/purchase_response.dart';
|
part 'model/purchase_response.dart';
|
||||||
part 'model/purchase_update.dart';
|
part 'model/purchase_update.dart';
|
||||||
|
158
mobile/openapi/lib/api/authentication_api.dart
generated
158
mobile/openapi/lib/api/authentication_api.dart
generated
@ -63,6 +63,86 @@ class AuthenticationApi {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Performs an HTTP 'PUT /auth/pin-code' operation and returns the [Response].
|
||||||
|
/// Parameters:
|
||||||
|
///
|
||||||
|
/// * [PinCodeChangeDto] pinCodeChangeDto (required):
|
||||||
|
Future<Response> changePinCodeWithHttpInfo(PinCodeChangeDto pinCodeChangeDto,) async {
|
||||||
|
// ignore: prefer_const_declarations
|
||||||
|
final apiPath = r'/auth/pin-code';
|
||||||
|
|
||||||
|
// ignore: prefer_final_locals
|
||||||
|
Object? postBody = pinCodeChangeDto;
|
||||||
|
|
||||||
|
final queryParams = <QueryParam>[];
|
||||||
|
final headerParams = <String, String>{};
|
||||||
|
final formParams = <String, String>{};
|
||||||
|
|
||||||
|
const contentTypes = <String>['application/json'];
|
||||||
|
|
||||||
|
|
||||||
|
return apiClient.invokeAPI(
|
||||||
|
apiPath,
|
||||||
|
'PUT',
|
||||||
|
queryParams,
|
||||||
|
postBody,
|
||||||
|
headerParams,
|
||||||
|
formParams,
|
||||||
|
contentTypes.isEmpty ? null : contentTypes.first,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parameters:
|
||||||
|
///
|
||||||
|
/// * [PinCodeChangeDto] pinCodeChangeDto (required):
|
||||||
|
Future<void> changePinCode(PinCodeChangeDto pinCodeChangeDto,) async {
|
||||||
|
final response = await changePinCodeWithHttpInfo(pinCodeChangeDto,);
|
||||||
|
if (response.statusCode >= HttpStatus.badRequest) {
|
||||||
|
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Performs an HTTP 'GET /auth/status' operation and returns the [Response].
|
||||||
|
Future<Response> getAuthStatusWithHttpInfo() async {
|
||||||
|
// ignore: prefer_const_declarations
|
||||||
|
final apiPath = r'/auth/status';
|
||||||
|
|
||||||
|
// ignore: prefer_final_locals
|
||||||
|
Object? postBody;
|
||||||
|
|
||||||
|
final queryParams = <QueryParam>[];
|
||||||
|
final headerParams = <String, String>{};
|
||||||
|
final formParams = <String, String>{};
|
||||||
|
|
||||||
|
const contentTypes = <String>[];
|
||||||
|
|
||||||
|
|
||||||
|
return apiClient.invokeAPI(
|
||||||
|
apiPath,
|
||||||
|
'GET',
|
||||||
|
queryParams,
|
||||||
|
postBody,
|
||||||
|
headerParams,
|
||||||
|
formParams,
|
||||||
|
contentTypes.isEmpty ? null : contentTypes.first,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<AuthStatusResponseDto?> getAuthStatus() async {
|
||||||
|
final response = await getAuthStatusWithHttpInfo();
|
||||||
|
if (response.statusCode >= HttpStatus.badRequest) {
|
||||||
|
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||||
|
}
|
||||||
|
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||||
|
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||||
|
// FormatException when trying to decode an empty string.
|
||||||
|
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||||
|
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AuthStatusResponseDto',) as AuthStatusResponseDto;
|
||||||
|
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// Performs an HTTP 'POST /auth/login' operation and returns the [Response].
|
/// Performs an HTTP 'POST /auth/login' operation and returns the [Response].
|
||||||
/// Parameters:
|
/// Parameters:
|
||||||
///
|
///
|
||||||
@ -151,6 +231,84 @@ class AuthenticationApi {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Performs an HTTP 'DELETE /auth/pin-code' operation and returns the [Response].
|
||||||
|
/// Parameters:
|
||||||
|
///
|
||||||
|
/// * [PinCodeChangeDto] pinCodeChangeDto (required):
|
||||||
|
Future<Response> resetPinCodeWithHttpInfo(PinCodeChangeDto pinCodeChangeDto,) async {
|
||||||
|
// ignore: prefer_const_declarations
|
||||||
|
final apiPath = r'/auth/pin-code';
|
||||||
|
|
||||||
|
// ignore: prefer_final_locals
|
||||||
|
Object? postBody = pinCodeChangeDto;
|
||||||
|
|
||||||
|
final queryParams = <QueryParam>[];
|
||||||
|
final headerParams = <String, String>{};
|
||||||
|
final formParams = <String, String>{};
|
||||||
|
|
||||||
|
const contentTypes = <String>['application/json'];
|
||||||
|
|
||||||
|
|
||||||
|
return apiClient.invokeAPI(
|
||||||
|
apiPath,
|
||||||
|
'DELETE',
|
||||||
|
queryParams,
|
||||||
|
postBody,
|
||||||
|
headerParams,
|
||||||
|
formParams,
|
||||||
|
contentTypes.isEmpty ? null : contentTypes.first,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parameters:
|
||||||
|
///
|
||||||
|
/// * [PinCodeChangeDto] pinCodeChangeDto (required):
|
||||||
|
Future<void> resetPinCode(PinCodeChangeDto pinCodeChangeDto,) async {
|
||||||
|
final response = await resetPinCodeWithHttpInfo(pinCodeChangeDto,);
|
||||||
|
if (response.statusCode >= HttpStatus.badRequest) {
|
||||||
|
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Performs an HTTP 'POST /auth/pin-code' operation and returns the [Response].
|
||||||
|
/// Parameters:
|
||||||
|
///
|
||||||
|
/// * [PinCodeSetupDto] pinCodeSetupDto (required):
|
||||||
|
Future<Response> setupPinCodeWithHttpInfo(PinCodeSetupDto pinCodeSetupDto,) async {
|
||||||
|
// ignore: prefer_const_declarations
|
||||||
|
final apiPath = r'/auth/pin-code';
|
||||||
|
|
||||||
|
// ignore: prefer_final_locals
|
||||||
|
Object? postBody = pinCodeSetupDto;
|
||||||
|
|
||||||
|
final queryParams = <QueryParam>[];
|
||||||
|
final headerParams = <String, String>{};
|
||||||
|
final formParams = <String, String>{};
|
||||||
|
|
||||||
|
const contentTypes = <String>['application/json'];
|
||||||
|
|
||||||
|
|
||||||
|
return apiClient.invokeAPI(
|
||||||
|
apiPath,
|
||||||
|
'POST',
|
||||||
|
queryParams,
|
||||||
|
postBody,
|
||||||
|
headerParams,
|
||||||
|
formParams,
|
||||||
|
contentTypes.isEmpty ? null : contentTypes.first,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parameters:
|
||||||
|
///
|
||||||
|
/// * [PinCodeSetupDto] pinCodeSetupDto (required):
|
||||||
|
Future<void> setupPinCode(PinCodeSetupDto pinCodeSetupDto,) async {
|
||||||
|
final response = await setupPinCodeWithHttpInfo(pinCodeSetupDto,);
|
||||||
|
if (response.statusCode >= HttpStatus.badRequest) {
|
||||||
|
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Performs an HTTP 'POST /auth/admin-sign-up' operation and returns the [Response].
|
/// Performs an HTTP 'POST /auth/admin-sign-up' operation and returns the [Response].
|
||||||
/// Parameters:
|
/// Parameters:
|
||||||
///
|
///
|
||||||
|
6
mobile/openapi/lib/api_client.dart
generated
6
mobile/openapi/lib/api_client.dart
generated
@ -272,6 +272,8 @@ class ApiClient {
|
|||||||
return AssetVisibilityTypeTransformer().decode(value);
|
return AssetVisibilityTypeTransformer().decode(value);
|
||||||
case 'AudioCodec':
|
case 'AudioCodec':
|
||||||
return AudioCodecTypeTransformer().decode(value);
|
return AudioCodecTypeTransformer().decode(value);
|
||||||
|
case 'AuthStatusResponseDto':
|
||||||
|
return AuthStatusResponseDto.fromJson(value);
|
||||||
case 'AvatarUpdate':
|
case 'AvatarUpdate':
|
||||||
return AvatarUpdate.fromJson(value);
|
return AvatarUpdate.fromJson(value);
|
||||||
case 'BulkIdResponseDto':
|
case 'BulkIdResponseDto':
|
||||||
@ -430,6 +432,10 @@ class ApiClient {
|
|||||||
return PersonUpdateDto.fromJson(value);
|
return PersonUpdateDto.fromJson(value);
|
||||||
case 'PersonWithFacesResponseDto':
|
case 'PersonWithFacesResponseDto':
|
||||||
return PersonWithFacesResponseDto.fromJson(value);
|
return PersonWithFacesResponseDto.fromJson(value);
|
||||||
|
case 'PinCodeChangeDto':
|
||||||
|
return PinCodeChangeDto.fromJson(value);
|
||||||
|
case 'PinCodeSetupDto':
|
||||||
|
return PinCodeSetupDto.fromJson(value);
|
||||||
case 'PlacesResponseDto':
|
case 'PlacesResponseDto':
|
||||||
return PlacesResponseDto.fromJson(value);
|
return PlacesResponseDto.fromJson(value);
|
||||||
case 'PurchaseResponse':
|
case 'PurchaseResponse':
|
||||||
|
107
mobile/openapi/lib/model/auth_status_response_dto.dart
generated
Normal file
107
mobile/openapi/lib/model/auth_status_response_dto.dart
generated
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
//
|
||||||
|
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||||
|
//
|
||||||
|
// @dart=2.18
|
||||||
|
|
||||||
|
// ignore_for_file: unused_element, unused_import
|
||||||
|
// ignore_for_file: always_put_required_named_parameters_first
|
||||||
|
// ignore_for_file: constant_identifier_names
|
||||||
|
// ignore_for_file: lines_longer_than_80_chars
|
||||||
|
|
||||||
|
part of openapi.api;
|
||||||
|
|
||||||
|
class AuthStatusResponseDto {
|
||||||
|
/// Returns a new [AuthStatusResponseDto] instance.
|
||||||
|
AuthStatusResponseDto({
|
||||||
|
required this.password,
|
||||||
|
required this.pinCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool password;
|
||||||
|
|
||||||
|
bool pinCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) => identical(this, other) || other is AuthStatusResponseDto &&
|
||||||
|
other.password == password &&
|
||||||
|
other.pinCode == pinCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
// ignore: unnecessary_parenthesis
|
||||||
|
(password.hashCode) +
|
||||||
|
(pinCode.hashCode);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'AuthStatusResponseDto[password=$password, pinCode=$pinCode]';
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final json = <String, dynamic>{};
|
||||||
|
json[r'password'] = this.password;
|
||||||
|
json[r'pinCode'] = this.pinCode;
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a new [AuthStatusResponseDto] instance and imports its values from
|
||||||
|
/// [value] if it's a [Map], null otherwise.
|
||||||
|
// ignore: prefer_constructors_over_static_methods
|
||||||
|
static AuthStatusResponseDto? fromJson(dynamic value) {
|
||||||
|
upgradeDto(value, "AuthStatusResponseDto");
|
||||||
|
if (value is Map) {
|
||||||
|
final json = value.cast<String, dynamic>();
|
||||||
|
|
||||||
|
return AuthStatusResponseDto(
|
||||||
|
password: mapValueOfType<bool>(json, r'password')!,
|
||||||
|
pinCode: mapValueOfType<bool>(json, r'pinCode')!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<AuthStatusResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final result = <AuthStatusResponseDto>[];
|
||||||
|
if (json is List && json.isNotEmpty) {
|
||||||
|
for (final row in json) {
|
||||||
|
final value = AuthStatusResponseDto.fromJson(row);
|
||||||
|
if (value != null) {
|
||||||
|
result.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.toList(growable: growable);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, AuthStatusResponseDto> mapFromJson(dynamic json) {
|
||||||
|
final map = <String, AuthStatusResponseDto>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
final value = AuthStatusResponseDto.fromJson(entry.value);
|
||||||
|
if (value != null) {
|
||||||
|
map[entry.key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// maps a json object with a list of AuthStatusResponseDto-objects as value to a dart map
|
||||||
|
static Map<String, List<AuthStatusResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final map = <String, List<AuthStatusResponseDto>>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
// ignore: parameter_assignments
|
||||||
|
json = json.cast<String, dynamic>();
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
map[entry.key] = AuthStatusResponseDto.listFromJson(entry.value, growable: growable,);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The list of required keys that must be present in a JSON.
|
||||||
|
static const requiredKeys = <String>{
|
||||||
|
'password',
|
||||||
|
'pinCode',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
133
mobile/openapi/lib/model/pin_code_change_dto.dart
generated
Normal file
133
mobile/openapi/lib/model/pin_code_change_dto.dart
generated
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
//
|
||||||
|
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||||
|
//
|
||||||
|
// @dart=2.18
|
||||||
|
|
||||||
|
// ignore_for_file: unused_element, unused_import
|
||||||
|
// ignore_for_file: always_put_required_named_parameters_first
|
||||||
|
// ignore_for_file: constant_identifier_names
|
||||||
|
// ignore_for_file: lines_longer_than_80_chars
|
||||||
|
|
||||||
|
part of openapi.api;
|
||||||
|
|
||||||
|
class PinCodeChangeDto {
|
||||||
|
/// Returns a new [PinCodeChangeDto] instance.
|
||||||
|
PinCodeChangeDto({
|
||||||
|
required this.newPinCode,
|
||||||
|
this.password,
|
||||||
|
this.pinCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
String newPinCode;
|
||||||
|
|
||||||
|
///
|
||||||
|
/// Please note: This property should have been non-nullable! Since the specification file
|
||||||
|
/// does not include a default value (using the "default:" property), however, the generated
|
||||||
|
/// source code must fall back to having a nullable type.
|
||||||
|
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||||
|
///
|
||||||
|
String? password;
|
||||||
|
|
||||||
|
///
|
||||||
|
/// Please note: This property should have been non-nullable! Since the specification file
|
||||||
|
/// does not include a default value (using the "default:" property), however, the generated
|
||||||
|
/// source code must fall back to having a nullable type.
|
||||||
|
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||||
|
///
|
||||||
|
String? pinCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) => identical(this, other) || other is PinCodeChangeDto &&
|
||||||
|
other.newPinCode == newPinCode &&
|
||||||
|
other.password == password &&
|
||||||
|
other.pinCode == pinCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
// ignore: unnecessary_parenthesis
|
||||||
|
(newPinCode.hashCode) +
|
||||||
|
(password == null ? 0 : password!.hashCode) +
|
||||||
|
(pinCode == null ? 0 : pinCode!.hashCode);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'PinCodeChangeDto[newPinCode=$newPinCode, password=$password, pinCode=$pinCode]';
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final json = <String, dynamic>{};
|
||||||
|
json[r'newPinCode'] = this.newPinCode;
|
||||||
|
if (this.password != null) {
|
||||||
|
json[r'password'] = this.password;
|
||||||
|
} else {
|
||||||
|
// json[r'password'] = null;
|
||||||
|
}
|
||||||
|
if (this.pinCode != null) {
|
||||||
|
json[r'pinCode'] = this.pinCode;
|
||||||
|
} else {
|
||||||
|
// json[r'pinCode'] = null;
|
||||||
|
}
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a new [PinCodeChangeDto] instance and imports its values from
|
||||||
|
/// [value] if it's a [Map], null otherwise.
|
||||||
|
// ignore: prefer_constructors_over_static_methods
|
||||||
|
static PinCodeChangeDto? fromJson(dynamic value) {
|
||||||
|
upgradeDto(value, "PinCodeChangeDto");
|
||||||
|
if (value is Map) {
|
||||||
|
final json = value.cast<String, dynamic>();
|
||||||
|
|
||||||
|
return PinCodeChangeDto(
|
||||||
|
newPinCode: mapValueOfType<String>(json, r'newPinCode')!,
|
||||||
|
password: mapValueOfType<String>(json, r'password'),
|
||||||
|
pinCode: mapValueOfType<String>(json, r'pinCode'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<PinCodeChangeDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final result = <PinCodeChangeDto>[];
|
||||||
|
if (json is List && json.isNotEmpty) {
|
||||||
|
for (final row in json) {
|
||||||
|
final value = PinCodeChangeDto.fromJson(row);
|
||||||
|
if (value != null) {
|
||||||
|
result.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.toList(growable: growable);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, PinCodeChangeDto> mapFromJson(dynamic json) {
|
||||||
|
final map = <String, PinCodeChangeDto>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
final value = PinCodeChangeDto.fromJson(entry.value);
|
||||||
|
if (value != null) {
|
||||||
|
map[entry.key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// maps a json object with a list of PinCodeChangeDto-objects as value to a dart map
|
||||||
|
static Map<String, List<PinCodeChangeDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final map = <String, List<PinCodeChangeDto>>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
// ignore: parameter_assignments
|
||||||
|
json = json.cast<String, dynamic>();
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
map[entry.key] = PinCodeChangeDto.listFromJson(entry.value, growable: growable,);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The list of required keys that must be present in a JSON.
|
||||||
|
static const requiredKeys = <String>{
|
||||||
|
'newPinCode',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
99
mobile/openapi/lib/model/pin_code_setup_dto.dart
generated
Normal file
99
mobile/openapi/lib/model/pin_code_setup_dto.dart
generated
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
//
|
||||||
|
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||||
|
//
|
||||||
|
// @dart=2.18
|
||||||
|
|
||||||
|
// ignore_for_file: unused_element, unused_import
|
||||||
|
// ignore_for_file: always_put_required_named_parameters_first
|
||||||
|
// ignore_for_file: constant_identifier_names
|
||||||
|
// ignore_for_file: lines_longer_than_80_chars
|
||||||
|
|
||||||
|
part of openapi.api;
|
||||||
|
|
||||||
|
class PinCodeSetupDto {
|
||||||
|
/// Returns a new [PinCodeSetupDto] instance.
|
||||||
|
PinCodeSetupDto({
|
||||||
|
required this.pinCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
String pinCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) => identical(this, other) || other is PinCodeSetupDto &&
|
||||||
|
other.pinCode == pinCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
// ignore: unnecessary_parenthesis
|
||||||
|
(pinCode.hashCode);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'PinCodeSetupDto[pinCode=$pinCode]';
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final json = <String, dynamic>{};
|
||||||
|
json[r'pinCode'] = this.pinCode;
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a new [PinCodeSetupDto] instance and imports its values from
|
||||||
|
/// [value] if it's a [Map], null otherwise.
|
||||||
|
// ignore: prefer_constructors_over_static_methods
|
||||||
|
static PinCodeSetupDto? fromJson(dynamic value) {
|
||||||
|
upgradeDto(value, "PinCodeSetupDto");
|
||||||
|
if (value is Map) {
|
||||||
|
final json = value.cast<String, dynamic>();
|
||||||
|
|
||||||
|
return PinCodeSetupDto(
|
||||||
|
pinCode: mapValueOfType<String>(json, r'pinCode')!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<PinCodeSetupDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final result = <PinCodeSetupDto>[];
|
||||||
|
if (json is List && json.isNotEmpty) {
|
||||||
|
for (final row in json) {
|
||||||
|
final value = PinCodeSetupDto.fromJson(row);
|
||||||
|
if (value != null) {
|
||||||
|
result.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.toList(growable: growable);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, PinCodeSetupDto> mapFromJson(dynamic json) {
|
||||||
|
final map = <String, PinCodeSetupDto>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
final value = PinCodeSetupDto.fromJson(entry.value);
|
||||||
|
if (value != null) {
|
||||||
|
map[entry.key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// maps a json object with a list of PinCodeSetupDto-objects as value to a dart map
|
||||||
|
static Map<String, List<PinCodeSetupDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final map = <String, List<PinCodeSetupDto>>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
// ignore: parameter_assignments
|
||||||
|
json = json.cast<String, dynamic>();
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
map[entry.key] = PinCodeSetupDto.listFromJson(entry.value, growable: growable,);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The list of required keys that must be present in a JSON.
|
||||||
|
static const requiredKeys = <String>{
|
||||||
|
'pinCode',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
13
mobile/openapi/lib/model/user_admin_update_dto.dart
generated
13
mobile/openapi/lib/model/user_admin_update_dto.dart
generated
@ -17,6 +17,7 @@ class UserAdminUpdateDto {
|
|||||||
this.email,
|
this.email,
|
||||||
this.name,
|
this.name,
|
||||||
this.password,
|
this.password,
|
||||||
|
this.pinCode,
|
||||||
this.quotaSizeInBytes,
|
this.quotaSizeInBytes,
|
||||||
this.shouldChangePassword,
|
this.shouldChangePassword,
|
||||||
this.storageLabel,
|
this.storageLabel,
|
||||||
@ -48,6 +49,8 @@ class UserAdminUpdateDto {
|
|||||||
///
|
///
|
||||||
String? password;
|
String? password;
|
||||||
|
|
||||||
|
String? pinCode;
|
||||||
|
|
||||||
/// Minimum value: 0
|
/// Minimum value: 0
|
||||||
int? quotaSizeInBytes;
|
int? quotaSizeInBytes;
|
||||||
|
|
||||||
@ -67,6 +70,7 @@ class UserAdminUpdateDto {
|
|||||||
other.email == email &&
|
other.email == email &&
|
||||||
other.name == name &&
|
other.name == name &&
|
||||||
other.password == password &&
|
other.password == password &&
|
||||||
|
other.pinCode == pinCode &&
|
||||||
other.quotaSizeInBytes == quotaSizeInBytes &&
|
other.quotaSizeInBytes == quotaSizeInBytes &&
|
||||||
other.shouldChangePassword == shouldChangePassword &&
|
other.shouldChangePassword == shouldChangePassword &&
|
||||||
other.storageLabel == storageLabel;
|
other.storageLabel == storageLabel;
|
||||||
@ -78,12 +82,13 @@ class UserAdminUpdateDto {
|
|||||||
(email == null ? 0 : email!.hashCode) +
|
(email == null ? 0 : email!.hashCode) +
|
||||||
(name == null ? 0 : name!.hashCode) +
|
(name == null ? 0 : name!.hashCode) +
|
||||||
(password == null ? 0 : password!.hashCode) +
|
(password == null ? 0 : password!.hashCode) +
|
||||||
|
(pinCode == null ? 0 : pinCode!.hashCode) +
|
||||||
(quotaSizeInBytes == null ? 0 : quotaSizeInBytes!.hashCode) +
|
(quotaSizeInBytes == null ? 0 : quotaSizeInBytes!.hashCode) +
|
||||||
(shouldChangePassword == null ? 0 : shouldChangePassword!.hashCode) +
|
(shouldChangePassword == null ? 0 : shouldChangePassword!.hashCode) +
|
||||||
(storageLabel == null ? 0 : storageLabel!.hashCode);
|
(storageLabel == null ? 0 : storageLabel!.hashCode);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => 'UserAdminUpdateDto[avatarColor=$avatarColor, email=$email, name=$name, password=$password, quotaSizeInBytes=$quotaSizeInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel]';
|
String toString() => 'UserAdminUpdateDto[avatarColor=$avatarColor, email=$email, name=$name, password=$password, pinCode=$pinCode, quotaSizeInBytes=$quotaSizeInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel]';
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
final json = <String, dynamic>{};
|
final json = <String, dynamic>{};
|
||||||
@ -107,6 +112,11 @@ class UserAdminUpdateDto {
|
|||||||
} else {
|
} else {
|
||||||
// json[r'password'] = null;
|
// json[r'password'] = null;
|
||||||
}
|
}
|
||||||
|
if (this.pinCode != null) {
|
||||||
|
json[r'pinCode'] = this.pinCode;
|
||||||
|
} else {
|
||||||
|
// json[r'pinCode'] = null;
|
||||||
|
}
|
||||||
if (this.quotaSizeInBytes != null) {
|
if (this.quotaSizeInBytes != null) {
|
||||||
json[r'quotaSizeInBytes'] = this.quotaSizeInBytes;
|
json[r'quotaSizeInBytes'] = this.quotaSizeInBytes;
|
||||||
} else {
|
} else {
|
||||||
@ -138,6 +148,7 @@ class UserAdminUpdateDto {
|
|||||||
email: mapValueOfType<String>(json, r'email'),
|
email: mapValueOfType<String>(json, r'email'),
|
||||||
name: mapValueOfType<String>(json, r'name'),
|
name: mapValueOfType<String>(json, r'name'),
|
||||||
password: mapValueOfType<String>(json, r'password'),
|
password: mapValueOfType<String>(json, r'password'),
|
||||||
|
pinCode: mapValueOfType<String>(json, r'pinCode'),
|
||||||
quotaSizeInBytes: mapValueOfType<int>(json, r'quotaSizeInBytes'),
|
quotaSizeInBytes: mapValueOfType<int>(json, r'quotaSizeInBytes'),
|
||||||
shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword'),
|
shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword'),
|
||||||
storageLabel: mapValueOfType<String>(json, r'storageLabel'),
|
storageLabel: mapValueOfType<String>(json, r'storageLabel'),
|
||||||
|
@ -2294,6 +2294,139 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/auth/pin-code": {
|
||||||
|
"delete": {
|
||||||
|
"operationId": "resetPinCode",
|
||||||
|
"parameters": [],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/PinCodeChangeDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_key": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Authentication"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"operationId": "setupPinCode",
|
||||||
|
"parameters": [],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/PinCodeSetupDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_key": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Authentication"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"put": {
|
||||||
|
"operationId": "changePinCode",
|
||||||
|
"parameters": [],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/PinCodeChangeDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_key": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Authentication"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/auth/status": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "getAuthStatus",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/AuthStatusResponseDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_key": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Authentication"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/auth/validateToken": {
|
"/auth/validateToken": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "validateAccessToken",
|
"operationId": "validateAccessToken",
|
||||||
@ -9031,6 +9164,21 @@
|
|||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"AuthStatusResponseDto": {
|
||||||
|
"properties": {
|
||||||
|
"password": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"pinCode": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"password",
|
||||||
|
"pinCode"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"AvatarUpdate": {
|
"AvatarUpdate": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"color": {
|
"color": {
|
||||||
@ -10964,6 +11112,37 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"PinCodeChangeDto": {
|
||||||
|
"properties": {
|
||||||
|
"newPinCode": {
|
||||||
|
"example": "123456",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"pinCode": {
|
||||||
|
"example": "123456",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"newPinCode"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"PinCodeSetupDto": {
|
||||||
|
"properties": {
|
||||||
|
"pinCode": {
|
||||||
|
"example": "123456",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"pinCode"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"PlacesResponseDto": {
|
"PlacesResponseDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"admin1name": {
|
"admin1name": {
|
||||||
@ -13958,6 +14137,11 @@
|
|||||||
"password": {
|
"password": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"pinCode": {
|
||||||
|
"example": "123456",
|
||||||
|
"nullable": true,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"quotaSizeInBytes": {
|
"quotaSizeInBytes": {
|
||||||
"format": "int64",
|
"format": "int64",
|
||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
|
@ -123,6 +123,7 @@ export type UserAdminUpdateDto = {
|
|||||||
email?: string;
|
email?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
|
pinCode?: string | null;
|
||||||
quotaSizeInBytes?: number | null;
|
quotaSizeInBytes?: number | null;
|
||||||
shouldChangePassword?: boolean;
|
shouldChangePassword?: boolean;
|
||||||
storageLabel?: string | null;
|
storageLabel?: string | null;
|
||||||
@ -510,6 +511,18 @@ export type LogoutResponseDto = {
|
|||||||
redirectUri: string;
|
redirectUri: string;
|
||||||
successful: boolean;
|
successful: boolean;
|
||||||
};
|
};
|
||||||
|
export type PinCodeChangeDto = {
|
||||||
|
newPinCode: string;
|
||||||
|
password?: string;
|
||||||
|
pinCode?: string;
|
||||||
|
};
|
||||||
|
export type PinCodeSetupDto = {
|
||||||
|
pinCode: string;
|
||||||
|
};
|
||||||
|
export type AuthStatusResponseDto = {
|
||||||
|
password: boolean;
|
||||||
|
pinCode: boolean;
|
||||||
|
};
|
||||||
export type ValidateAccessTokenResponseDto = {
|
export type ValidateAccessTokenResponseDto = {
|
||||||
authStatus: boolean;
|
authStatus: boolean;
|
||||||
};
|
};
|
||||||
@ -2017,6 +2030,41 @@ export function logout(opts?: Oazapfts.RequestOpts) {
|
|||||||
method: "POST"
|
method: "POST"
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
export function resetPinCode({ pinCodeChangeDto }: {
|
||||||
|
pinCodeChangeDto: PinCodeChangeDto;
|
||||||
|
}, opts?: Oazapfts.RequestOpts) {
|
||||||
|
return oazapfts.ok(oazapfts.fetchText("/auth/pin-code", oazapfts.json({
|
||||||
|
...opts,
|
||||||
|
method: "DELETE",
|
||||||
|
body: pinCodeChangeDto
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
export function setupPinCode({ pinCodeSetupDto }: {
|
||||||
|
pinCodeSetupDto: PinCodeSetupDto;
|
||||||
|
}, opts?: Oazapfts.RequestOpts) {
|
||||||
|
return oazapfts.ok(oazapfts.fetchText("/auth/pin-code", oazapfts.json({
|
||||||
|
...opts,
|
||||||
|
method: "POST",
|
||||||
|
body: pinCodeSetupDto
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
export function changePinCode({ pinCodeChangeDto }: {
|
||||||
|
pinCodeChangeDto: PinCodeChangeDto;
|
||||||
|
}, opts?: Oazapfts.RequestOpts) {
|
||||||
|
return oazapfts.ok(oazapfts.fetchText("/auth/pin-code", oazapfts.json({
|
||||||
|
...opts,
|
||||||
|
method: "PUT",
|
||||||
|
body: pinCodeChangeDto
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
export function getAuthStatus(opts?: Oazapfts.RequestOpts) {
|
||||||
|
return oazapfts.ok(oazapfts.fetchJson<{
|
||||||
|
status: 200;
|
||||||
|
data: AuthStatusResponseDto;
|
||||||
|
}>("/auth/status", {
|
||||||
|
...opts
|
||||||
|
}));
|
||||||
|
}
|
||||||
export function validateAccessToken(opts?: Oazapfts.RequestOpts) {
|
export function validateAccessToken(opts?: Oazapfts.RequestOpts) {
|
||||||
return oazapfts.ok(oazapfts.fetchJson<{
|
return oazapfts.ok(oazapfts.fetchJson<{
|
||||||
status: 200;
|
status: 200;
|
||||||
|
191
server/package-lock.json
generated
191
server/package-lock.json
generated
@ -23,7 +23,7 @@
|
|||||||
"@opentelemetry/context-async-hooks": "^2.0.0",
|
"@opentelemetry/context-async-hooks": "^2.0.0",
|
||||||
"@opentelemetry/exporter-prometheus": "^0.200.0",
|
"@opentelemetry/exporter-prometheus": "^0.200.0",
|
||||||
"@opentelemetry/sdk-node": "^0.200.0",
|
"@opentelemetry/sdk-node": "^0.200.0",
|
||||||
"@react-email/components": "^0.0.37",
|
"@react-email/components": "^0.0.38",
|
||||||
"@socket.io/redis-adapter": "^8.3.0",
|
"@socket.io/redis-adapter": "^8.3.0",
|
||||||
"archiver": "^7.0.0",
|
"archiver": "^7.0.0",
|
||||||
"async-lock": "^1.4.0",
|
"async-lock": "^1.4.0",
|
||||||
@ -1016,9 +1016,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@eslint/js": {
|
"node_modules/@eslint/js": {
|
||||||
"version": "9.25.1",
|
"version": "9.26.0",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.25.1.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.26.0.tgz",
|
||||||
"integrity": "sha512-dEIwmjntEx8u3Uvv+kr3PDeeArL8Hw07H9kyYxCjnM9pBjfEhk6uLXSchxxzgiwtRhhzVzqmUSDFBOi1TuZ7qg==",
|
"integrity": "sha512-I9XlJawFdSMvWjDt6wksMCrgns5ggLNfFwFvnShsleWruvXM514Qxk8V246efTw+eo9JABvVz+u3q2RiAowKxQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@ -2118,6 +2118,28 @@
|
|||||||
"integrity": "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==",
|
"integrity": "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@modelcontextprotocol/sdk": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-9LfmxKTb1v+vUS1/emSk1f5ePmTLkb9Le9AxOB5T0XM59EUumwcS45z05h7aiZx3GI0Bl7mjb3FMEglYj+acuQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"content-type": "^1.0.5",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"cross-spawn": "^7.0.3",
|
||||||
|
"eventsource": "^3.0.2",
|
||||||
|
"express": "^5.0.1",
|
||||||
|
"express-rate-limit": "^7.5.0",
|
||||||
|
"pkce-challenge": "^5.0.0",
|
||||||
|
"raw-body": "^3.0.0",
|
||||||
|
"zod": "^3.23.8",
|
||||||
|
"zod-to-json-schema": "^3.24.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
|
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz",
|
||||||
@ -2952,22 +2974,22 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@opentelemetry/auto-instrumentations-node": {
|
"node_modules/@opentelemetry/auto-instrumentations-node": {
|
||||||
"version": "0.58.0",
|
"version": "0.58.1",
|
||||||
"resolved": "https://registry.npmjs.org/@opentelemetry/auto-instrumentations-node/-/auto-instrumentations-node-0.58.0.tgz",
|
"resolved": "https://registry.npmjs.org/@opentelemetry/auto-instrumentations-node/-/auto-instrumentations-node-0.58.1.tgz",
|
||||||
"integrity": "sha512-gtqPqkXp8TG6vrmbzAJUKjJm3nrCiVGgImlV1tj8lsVqpnKDCB1Kl7bCcXod36+Tq/O4rCeTDmW90dCHeuv9jQ==",
|
"integrity": "sha512-hAsNw5XtFTytQ6GrCspIwKKSamXQGfAvRfqOL93VTqaI1WFBhndyXsNrjAzqULvK0JwMJOuZb77ckdrvJrW3vA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opentelemetry/instrumentation": "^0.200.0",
|
"@opentelemetry/instrumentation": "^0.200.0",
|
||||||
"@opentelemetry/instrumentation-amqplib": "^0.47.0",
|
"@opentelemetry/instrumentation-amqplib": "^0.47.0",
|
||||||
"@opentelemetry/instrumentation-aws-lambda": "^0.51.0",
|
"@opentelemetry/instrumentation-aws-lambda": "^0.51.1",
|
||||||
"@opentelemetry/instrumentation-aws-sdk": "^0.51.0",
|
"@opentelemetry/instrumentation-aws-sdk": "^0.52.0",
|
||||||
"@opentelemetry/instrumentation-bunyan": "^0.46.0",
|
"@opentelemetry/instrumentation-bunyan": "^0.46.0",
|
||||||
"@opentelemetry/instrumentation-cassandra-driver": "^0.46.0",
|
"@opentelemetry/instrumentation-cassandra-driver": "^0.46.0",
|
||||||
"@opentelemetry/instrumentation-connect": "^0.44.0",
|
"@opentelemetry/instrumentation-connect": "^0.44.0",
|
||||||
"@opentelemetry/instrumentation-cucumber": "^0.15.0",
|
"@opentelemetry/instrumentation-cucumber": "^0.15.0",
|
||||||
"@opentelemetry/instrumentation-dataloader": "^0.17.0",
|
"@opentelemetry/instrumentation-dataloader": "^0.17.0",
|
||||||
"@opentelemetry/instrumentation-dns": "^0.44.0",
|
"@opentelemetry/instrumentation-dns": "^0.44.0",
|
||||||
"@opentelemetry/instrumentation-express": "^0.48.1",
|
"@opentelemetry/instrumentation-express": "^0.49.0",
|
||||||
"@opentelemetry/instrumentation-fastify": "^0.45.0",
|
"@opentelemetry/instrumentation-fastify": "^0.45.0",
|
||||||
"@opentelemetry/instrumentation-fs": "^0.20.0",
|
"@opentelemetry/instrumentation-fs": "^0.20.0",
|
||||||
"@opentelemetry/instrumentation-generic-pool": "^0.44.0",
|
"@opentelemetry/instrumentation-generic-pool": "^0.44.0",
|
||||||
@ -2976,13 +2998,13 @@
|
|||||||
"@opentelemetry/instrumentation-hapi": "^0.46.0",
|
"@opentelemetry/instrumentation-hapi": "^0.46.0",
|
||||||
"@opentelemetry/instrumentation-http": "^0.200.0",
|
"@opentelemetry/instrumentation-http": "^0.200.0",
|
||||||
"@opentelemetry/instrumentation-ioredis": "^0.48.0",
|
"@opentelemetry/instrumentation-ioredis": "^0.48.0",
|
||||||
"@opentelemetry/instrumentation-kafkajs": "^0.9.1",
|
"@opentelemetry/instrumentation-kafkajs": "^0.9.2",
|
||||||
"@opentelemetry/instrumentation-knex": "^0.45.0",
|
"@opentelemetry/instrumentation-knex": "^0.45.0",
|
||||||
"@opentelemetry/instrumentation-koa": "^0.48.0",
|
"@opentelemetry/instrumentation-koa": "^0.48.0",
|
||||||
"@opentelemetry/instrumentation-lru-memoizer": "^0.45.0",
|
"@opentelemetry/instrumentation-lru-memoizer": "^0.45.0",
|
||||||
"@opentelemetry/instrumentation-memcached": "^0.44.0",
|
"@opentelemetry/instrumentation-memcached": "^0.44.0",
|
||||||
"@opentelemetry/instrumentation-mongodb": "^0.53.0",
|
"@opentelemetry/instrumentation-mongodb": "^0.53.0",
|
||||||
"@opentelemetry/instrumentation-mongoose": "^0.47.0",
|
"@opentelemetry/instrumentation-mongoose": "^0.47.1",
|
||||||
"@opentelemetry/instrumentation-mysql": "^0.46.0",
|
"@opentelemetry/instrumentation-mysql": "^0.46.0",
|
||||||
"@opentelemetry/instrumentation-mysql2": "^0.46.0",
|
"@opentelemetry/instrumentation-mysql2": "^0.46.0",
|
||||||
"@opentelemetry/instrumentation-nestjs-core": "^0.46.0",
|
"@opentelemetry/instrumentation-nestjs-core": "^0.46.0",
|
||||||
@ -3308,9 +3330,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@opentelemetry/instrumentation-aws-lambda": {
|
"node_modules/@opentelemetry/instrumentation-aws-lambda": {
|
||||||
"version": "0.51.0",
|
"version": "0.51.1",
|
||||||
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-aws-lambda/-/instrumentation-aws-lambda-0.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-aws-lambda/-/instrumentation-aws-lambda-0.51.1.tgz",
|
||||||
"integrity": "sha512-yPtnDum6vykhxA1xZ2kKc3DGmrLdbRAkJG0HiQUcOas47j716wmtqsLCctHyXgO0NpmS/BCzbUnOxxPG6kln7A==",
|
"integrity": "sha512-DxUihz1ZcJtkCKFMnsr5IpQtU1TFnz/QhTEkcb95yfVvmdWx97ezbcxE4lGFjvQYMT8q2NsZjor8s8W/jrMU2w==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opentelemetry/instrumentation": "^0.200.0",
|
"@opentelemetry/instrumentation": "^0.200.0",
|
||||||
@ -3325,9 +3347,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@opentelemetry/instrumentation-aws-sdk": {
|
"node_modules/@opentelemetry/instrumentation-aws-sdk": {
|
||||||
"version": "0.51.0",
|
"version": "0.52.0",
|
||||||
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-aws-sdk/-/instrumentation-aws-sdk-0.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-aws-sdk/-/instrumentation-aws-sdk-0.52.0.tgz",
|
||||||
"integrity": "sha512-NfmdJqrgJyAPGzPJk2bNl8vBn2kbDIHyTmKVNWhcQWh0VCA5aspi75Gsp5tHmLqk26VAtVtUEDZwK3nApFEtzw==",
|
"integrity": "sha512-xMnghwQP/vO9hNNufaHW3SgNprifLPqmssAQ/zjRopbxa6wpBqunWfKYRRoyu89Xlw0X8/hGNoPEh+CIocCryg==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opentelemetry/core": "^2.0.0",
|
"@opentelemetry/core": "^2.0.0",
|
||||||
@ -3440,9 +3462,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@opentelemetry/instrumentation-express": {
|
"node_modules/@opentelemetry/instrumentation-express": {
|
||||||
"version": "0.48.1",
|
"version": "0.49.0",
|
||||||
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-express/-/instrumentation-express-0.48.1.tgz",
|
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-express/-/instrumentation-express-0.49.0.tgz",
|
||||||
"integrity": "sha512-j8NYOf9DRWtchbWor/zA0poI42TpZG9tViIKA0e1lC+6MshTqSJYtgNv8Fn1sx1Wn/TRyp+5OgSXiE4LDfvpEg==",
|
"integrity": "sha512-j1hbIZzbu7jLQfI/Hz0wHDaniiSWdC3B8/UdH0CEd4lcO8y0pQlz4UTReBaL1BzbkwUhbg6oHuK+m8DXklQPtA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opentelemetry/core": "^2.0.0",
|
"@opentelemetry/core": "^2.0.0",
|
||||||
@ -3588,9 +3610,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@opentelemetry/instrumentation-kafkajs": {
|
"node_modules/@opentelemetry/instrumentation-kafkajs": {
|
||||||
"version": "0.9.1",
|
"version": "0.9.2",
|
||||||
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.9.2.tgz",
|
||||||
"integrity": "sha512-eGl5WKBqd0unOKm7PJKjEa1G+ac9nvpDjyv870nUYuSnUkyDc/Fag5keddIjHixTJwRp3FmyP7n+AadAjh52Vw==",
|
"integrity": "sha512-aRnrLK3gQv6LP64oiXEDdRVwxNe7AvS98SCtNWEGhHy4nv3CdxpN7b7NU53g3PCF7uPQZ1fVW2C6Xc2tt1SIkg==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opentelemetry/instrumentation": "^0.200.0",
|
"@opentelemetry/instrumentation": "^0.200.0",
|
||||||
@ -3685,9 +3707,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@opentelemetry/instrumentation-mongoose": {
|
"node_modules/@opentelemetry/instrumentation-mongoose": {
|
||||||
"version": "0.47.0",
|
"version": "0.47.1",
|
||||||
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.47.0.tgz",
|
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.47.1.tgz",
|
||||||
"integrity": "sha512-zg4ixMNmuACda75eOFa1m5h794zC9wp397stX0LAZvOylSb6dWT52P6ElkVQMV42C/27liEdQWxpabsamB+XPQ==",
|
"integrity": "sha512-0OcL5YpZX9PtF55Oi1RtWUdjElJscR9u6NzAdww81EQc3wFfQWmdREUEBeWaDH5jpiomdFp6zDXms622ofEOjg==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opentelemetry/core": "^2.0.0",
|
"@opentelemetry/core": "^2.0.0",
|
||||||
@ -4446,9 +4468,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-email/components": {
|
"node_modules/@react-email/components": {
|
||||||
"version": "0.0.37",
|
"version": "0.0.38",
|
||||||
"resolved": "https://registry.npmjs.org/@react-email/components/-/components-0.0.37.tgz",
|
"resolved": "https://registry.npmjs.org/@react-email/components/-/components-0.0.38.tgz",
|
||||||
"integrity": "sha512-M4MKALwezAf9uVMQHsR6k/MvfQ9H3xyrOppGfOiznPnjcv+/7oWiHERzL7Ani5nrCsc+fhc70zybpwtVQ/NSHQ==",
|
"integrity": "sha512-2cjMBZsSPjD1Iyur/MzGrgW/n5A6ONOJQ97pNaVOClxz/EaqNZTo1lFmKdH7p54P7LG9ZxRXxoTe2075VCCGQA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-email/body": "0.0.11",
|
"@react-email/body": "0.0.11",
|
||||||
@ -4470,7 +4492,7 @@
|
|||||||
"@react-email/row": "0.0.12",
|
"@react-email/row": "0.0.12",
|
||||||
"@react-email/section": "0.0.16",
|
"@react-email/section": "0.0.16",
|
||||||
"@react-email/tailwind": "1.0.5",
|
"@react-email/tailwind": "1.0.5",
|
||||||
"@react-email/text": "0.1.2"
|
"@react-email/text": "0.1.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
@ -4654,9 +4676,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-email/text": {
|
"node_modules/@react-email/text": {
|
||||||
"version": "0.1.2",
|
"version": "0.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.1.3.tgz",
|
||||||
"integrity": "sha512-B5xDDBxgYpu/j7K4PSXuGmXgEvTheMyvnVXf/Md2u9dhvBHq65CPvSqYxDM1vjDjKd39GQEI7dqT2QIjER2DGA==",
|
"integrity": "sha512-H22KR54MXUg29a+1/lTfg9oCQA65V8+TL4v19OzV7RsOxnEnzGOc287XKh8vc+v7ENewrMV97BzUPOnKz3bqkA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
@ -7022,9 +7044,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/bullmq": {
|
"node_modules/bullmq": {
|
||||||
"version": "5.52.0",
|
"version": "5.52.1",
|
||||||
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.52.0.tgz",
|
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.52.1.tgz",
|
||||||
"integrity": "sha512-2PRR7DuH4iFjrIam5kL08VLHe1FCZtr1jsL3Um/18EML9Gd7w9eFgzlriaiYUyUxU0gFVql0sijo1aBcoTrTTA==",
|
"integrity": "sha512-u7CSV9wID3MBEX2DNubEErbAlrADgm8abUBAi6h8rQTnuTkhhgMs2iD7uhqplK8lIgUOkBIW3sDJWaMSInH47A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cron-parser": "^4.9.0",
|
"cron-parser": "^4.9.0",
|
||||||
@ -7468,13 +7490,13 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/class-validator": {
|
"node_modules/class-validator": {
|
||||||
"version": "0.14.1",
|
"version": "0.14.2",
|
||||||
"resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.1.tgz",
|
"resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.2.tgz",
|
||||||
"integrity": "sha512-2VEG9JICxIqTpoK1eMzZqaV+u/EiwEJkMGzTrZf6sU/fwsnOITVgYJ8yojSy6CaXtO9V0Cc6ZQZ8h8m4UBuLwQ==",
|
"integrity": "sha512-3kMVRF2io8N8pY1IFIXlho9r8IPUUIfHe2hYVtiebvAzU2XeQFXTv+XI4WX+TnXmtwXMDcjngcpkiPM0O9PvLw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/validator": "^13.11.8",
|
"@types/validator": "^13.11.8",
|
||||||
"libphonenumber-js": "^1.10.53",
|
"libphonenumber-js": "^1.11.1",
|
||||||
"validator": "^13.9.0"
|
"validator": "^13.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -8875,9 +8897,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint": {
|
"node_modules/eslint": {
|
||||||
"version": "9.25.1",
|
"version": "9.26.0",
|
||||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.25.1.tgz",
|
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.26.0.tgz",
|
||||||
"integrity": "sha512-E6Mtz9oGQWDCpV12319d59n4tx9zOTXSTmc8BLVxBx+G/0RdM5MvEEJLU9c0+aleoePYYgVTOsRblx433qmhWQ==",
|
"integrity": "sha512-Hx0MOjPh6uK9oq9nVsATZKE/Wlbai7KFjfCuw9UHaguDW3x+HF0O5nIi3ud39TWgrTjTO5nHxmL3R1eANinWHQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -8887,11 +8909,12 @@
|
|||||||
"@eslint/config-helpers": "^0.2.1",
|
"@eslint/config-helpers": "^0.2.1",
|
||||||
"@eslint/core": "^0.13.0",
|
"@eslint/core": "^0.13.0",
|
||||||
"@eslint/eslintrc": "^3.3.1",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
"@eslint/js": "9.25.1",
|
"@eslint/js": "9.26.0",
|
||||||
"@eslint/plugin-kit": "^0.2.8",
|
"@eslint/plugin-kit": "^0.2.8",
|
||||||
"@humanfs/node": "^0.16.6",
|
"@humanfs/node": "^0.16.6",
|
||||||
"@humanwhocodes/module-importer": "^1.0.1",
|
"@humanwhocodes/module-importer": "^1.0.1",
|
||||||
"@humanwhocodes/retry": "^0.4.2",
|
"@humanwhocodes/retry": "^0.4.2",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.8.0",
|
||||||
"@types/estree": "^1.0.6",
|
"@types/estree": "^1.0.6",
|
||||||
"@types/json-schema": "^7.0.15",
|
"@types/json-schema": "^7.0.15",
|
||||||
"ajv": "^6.12.4",
|
"ajv": "^6.12.4",
|
||||||
@ -8915,7 +8938,8 @@
|
|||||||
"lodash.merge": "^4.6.2",
|
"lodash.merge": "^4.6.2",
|
||||||
"minimatch": "^3.1.2",
|
"minimatch": "^3.1.2",
|
||||||
"natural-compare": "^1.4.0",
|
"natural-compare": "^1.4.0",
|
||||||
"optionator": "^0.9.3"
|
"optionator": "^0.9.3",
|
||||||
|
"zod": "^3.24.2"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"eslint": "bin/eslint.js"
|
"eslint": "bin/eslint.js"
|
||||||
@ -8949,9 +8973,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint-plugin-prettier": {
|
"node_modules/eslint-plugin-prettier": {
|
||||||
"version": "5.2.6",
|
"version": "5.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.3.1.tgz",
|
||||||
"integrity": "sha512-mUcf7QG2Tjk7H055Jk0lGBjbgDnfrvqjhXh9t2xLMSCjZVcw9Rb1V6sVNXO0th3jgeO7zllWPTNRil3JW94TnQ==",
|
"integrity": "sha512-vad9VWgEm9xaVXRNmb4aeOt0PWDc61IAdzghkbYQ2wavgax148iKoX1rNJcgkBGCipzLzOnHYVgL7xudM9yccQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -9187,6 +9211,29 @@
|
|||||||
"node": ">=0.8.x"
|
"node": ">=0.8.x"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventsource": {
|
||||||
|
"version": "3.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
|
||||||
|
"integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"eventsource-parser": "^3.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/eventsource-parser": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-VARTJ9CYeuQYb0pZEPbzi740OWFgpHe7AYJ2WFZVnUDUQp5Dk2yJUgF36YsZ81cOyxT0QxmXD2EQpapAouzWVA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/exiftool-vendored": {
|
"node_modules/exiftool-vendored": {
|
||||||
"version": "28.8.0",
|
"version": "28.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.8.0.tgz",
|
||||||
@ -9273,6 +9320,22 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/express-rate-limit": {
|
||||||
|
"version": "7.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.0.tgz",
|
||||||
|
"integrity": "sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/express-rate-limit"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"express": "^4.11 || 5 || ^5.0.0-beta.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/express/node_modules/cookie": {
|
"node_modules/express/node_modules/cookie": {
|
||||||
"version": "0.7.2",
|
"version": "0.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||||
@ -13370,6 +13433,16 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pkce-challenge": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pluralize": {
|
"node_modules/pluralize": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
|
||||||
@ -18122,6 +18195,26 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 14"
|
"node": ">= 14"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zod": {
|
||||||
|
"version": "3.24.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.4.tgz",
|
||||||
|
"integrity": "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zod-to-json-schema": {
|
||||||
|
"version": "3.24.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz",
|
||||||
|
"integrity": "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^3.24.1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -48,7 +48,7 @@
|
|||||||
"@opentelemetry/context-async-hooks": "^2.0.0",
|
"@opentelemetry/context-async-hooks": "^2.0.0",
|
||||||
"@opentelemetry/exporter-prometheus": "^0.200.0",
|
"@opentelemetry/exporter-prometheus": "^0.200.0",
|
||||||
"@opentelemetry/sdk-node": "^0.200.0",
|
"@opentelemetry/sdk-node": "^0.200.0",
|
||||||
"@react-email/components": "^0.0.37",
|
"@react-email/components": "^0.0.38",
|
||||||
"@socket.io/redis-adapter": "^8.3.0",
|
"@socket.io/redis-adapter": "^8.3.0",
|
||||||
"archiver": "^7.0.0",
|
"archiver": "^7.0.0",
|
||||||
"async-lock": "^1.4.0",
|
"async-lock": "^1.4.0",
|
||||||
|
@ -142,4 +142,50 @@ describe(AuthController.name, () => {
|
|||||||
expect(ctx.authenticate).toHaveBeenCalled();
|
expect(ctx.authenticate).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('POST /auth/pin-code', () => {
|
||||||
|
it('should be an authenticated route', async () => {
|
||||||
|
await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: '123456' });
|
||||||
|
expect(ctx.authenticate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject 5 digits', async () => {
|
||||||
|
const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: '12345' });
|
||||||
|
expect(status).toEqual(400);
|
||||||
|
expect(body).toEqual(errorDto.badRequest(['pinCode must be a 6-digit numeric string']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject 7 digits', async () => {
|
||||||
|
const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: '1234567' });
|
||||||
|
expect(status).toEqual(400);
|
||||||
|
expect(body).toEqual(errorDto.badRequest(['pinCode must be a 6-digit numeric string']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject non-numbers', async () => {
|
||||||
|
const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: 'A12345' });
|
||||||
|
expect(status).toEqual(400);
|
||||||
|
expect(body).toEqual(errorDto.badRequest(['pinCode must be a 6-digit numeric string']));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PUT /auth/pin-code', () => {
|
||||||
|
it('should be an authenticated route', async () => {
|
||||||
|
await request(ctx.getHttpServer()).put('/auth/pin-code').send({ pinCode: '123456', newPinCode: '654321' });
|
||||||
|
expect(ctx.authenticate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /auth/pin-code', () => {
|
||||||
|
it('should be an authenticated route', async () => {
|
||||||
|
await request(ctx.getHttpServer()).delete('/auth/pin-code').send({ pinCode: '123456' });
|
||||||
|
expect(ctx.authenticate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /auth/status', () => {
|
||||||
|
it('should be an authenticated route', async () => {
|
||||||
|
await request(ctx.getHttpServer()).get('/auth/status');
|
||||||
|
expect(ctx.authenticate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,12 +1,15 @@
|
|||||||
import { Body, Controller, HttpCode, HttpStatus, Post, Req, Res } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Post, Put, Req, Res } from '@nestjs/common';
|
||||||
import { ApiTags } from '@nestjs/swagger';
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import {
|
import {
|
||||||
AuthDto,
|
AuthDto,
|
||||||
|
AuthStatusResponseDto,
|
||||||
ChangePasswordDto,
|
ChangePasswordDto,
|
||||||
LoginCredentialDto,
|
LoginCredentialDto,
|
||||||
LoginResponseDto,
|
LoginResponseDto,
|
||||||
LogoutResponseDto,
|
LogoutResponseDto,
|
||||||
|
PinCodeChangeDto,
|
||||||
|
PinCodeSetupDto,
|
||||||
SignUpDto,
|
SignUpDto,
|
||||||
ValidateAccessTokenResponseDto,
|
ValidateAccessTokenResponseDto,
|
||||||
} from 'src/dtos/auth.dto';
|
} from 'src/dtos/auth.dto';
|
||||||
@ -74,4 +77,28 @@ export class AuthController {
|
|||||||
ImmichCookie.IS_AUTHENTICATED,
|
ImmichCookie.IS_AUTHENTICATED,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('status')
|
||||||
|
@Authenticated()
|
||||||
|
getAuthStatus(@Auth() auth: AuthDto): Promise<AuthStatusResponseDto> {
|
||||||
|
return this.service.getAuthStatus(auth);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('pin-code')
|
||||||
|
@Authenticated()
|
||||||
|
setupPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeSetupDto): Promise<void> {
|
||||||
|
return this.service.setupPinCode(auth, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('pin-code')
|
||||||
|
@Authenticated()
|
||||||
|
async changePinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeChangeDto): Promise<void> {
|
||||||
|
return this.service.changePinCode(auth, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('pin-code')
|
||||||
|
@Authenticated()
|
||||||
|
async resetPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeChangeDto): Promise<void> {
|
||||||
|
return this.service.resetPinCode(auth, dto);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,7 +3,7 @@ import { Transform } from 'class-transformer';
|
|||||||
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
|
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
|
||||||
import { AuthApiKey, AuthSession, AuthSharedLink, AuthUser, UserAdmin } from 'src/database';
|
import { AuthApiKey, AuthSession, AuthSharedLink, AuthUser, UserAdmin } from 'src/database';
|
||||||
import { ImmichCookie } from 'src/enum';
|
import { ImmichCookie } from 'src/enum';
|
||||||
import { Optional, toEmail } from 'src/validation';
|
import { Optional, PinCode, toEmail } from 'src/validation';
|
||||||
|
|
||||||
export type CookieResponse = {
|
export type CookieResponse = {
|
||||||
isSecure: boolean;
|
isSecure: boolean;
|
||||||
@ -78,6 +78,26 @@ export class ChangePasswordDto {
|
|||||||
newPassword!: string;
|
newPassword!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class PinCodeSetupDto {
|
||||||
|
@PinCode()
|
||||||
|
pinCode!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PinCodeResetDto {
|
||||||
|
@PinCode({ optional: true })
|
||||||
|
pinCode?: string;
|
||||||
|
|
||||||
|
@Optional()
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PinCodeChangeDto extends PinCodeResetDto {
|
||||||
|
@PinCode()
|
||||||
|
newPinCode!: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class ValidateAccessTokenResponseDto {
|
export class ValidateAccessTokenResponseDto {
|
||||||
authStatus!: boolean;
|
authStatus!: boolean;
|
||||||
}
|
}
|
||||||
@ -114,3 +134,8 @@ export class OAuthConfigDto {
|
|||||||
export class OAuthAuthorizeResponseDto {
|
export class OAuthAuthorizeResponseDto {
|
||||||
url!: string;
|
url!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class AuthStatusResponseDto {
|
||||||
|
pinCode!: boolean;
|
||||||
|
password!: boolean;
|
||||||
|
}
|
||||||
|
@ -4,7 +4,7 @@ import { IsBoolean, IsEmail, IsEnum, IsNotEmpty, IsNumber, IsString, Min } from
|
|||||||
import { User, UserAdmin } from 'src/database';
|
import { User, UserAdmin } from 'src/database';
|
||||||
import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum';
|
import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum';
|
||||||
import { UserMetadataItem } from 'src/types';
|
import { UserMetadataItem } from 'src/types';
|
||||||
import { Optional, ValidateBoolean, toEmail, toSanitized } from 'src/validation';
|
import { Optional, PinCode, ValidateBoolean, toEmail, toSanitized } from 'src/validation';
|
||||||
|
|
||||||
export class UserUpdateMeDto {
|
export class UserUpdateMeDto {
|
||||||
@Optional()
|
@Optional()
|
||||||
@ -116,6 +116,9 @@ export class UserAdminUpdateDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
password?: string;
|
password?: string;
|
||||||
|
|
||||||
|
@PinCode({ optional: true, nullable: true, emptyToNull: true })
|
||||||
|
pinCode?: string | null;
|
||||||
|
|
||||||
@Optional()
|
@Optional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
@ -87,6 +87,16 @@ where
|
|||||||
"users"."isAdmin" = $1
|
"users"."isAdmin" = $1
|
||||||
and "users"."deletedAt" is null
|
and "users"."deletedAt" is null
|
||||||
|
|
||||||
|
-- UserRepository.getForPinCode
|
||||||
|
select
|
||||||
|
"users"."pinCode",
|
||||||
|
"users"."password"
|
||||||
|
from
|
||||||
|
"users"
|
||||||
|
where
|
||||||
|
"users"."id" = $1
|
||||||
|
and "users"."deletedAt" is null
|
||||||
|
|
||||||
-- UserRepository.getByEmail
|
-- UserRepository.getByEmail
|
||||||
select
|
select
|
||||||
"id",
|
"id",
|
||||||
|
@ -89,13 +89,23 @@ export class UserRepository {
|
|||||||
return !!admin;
|
return !!admin;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GenerateSql({ params: [DummyValue.UUID] })
|
||||||
|
getForPinCode(id: string) {
|
||||||
|
return this.db
|
||||||
|
.selectFrom('users')
|
||||||
|
.select(['users.pinCode', 'users.password'])
|
||||||
|
.where('users.id', '=', id)
|
||||||
|
.where('users.deletedAt', 'is', null)
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.EMAIL] })
|
@GenerateSql({ params: [DummyValue.EMAIL] })
|
||||||
getByEmail(email: string, withPassword?: boolean) {
|
getByEmail(email: string, options?: { withPassword?: boolean }) {
|
||||||
return this.db
|
return this.db
|
||||||
.selectFrom('users')
|
.selectFrom('users')
|
||||||
.select(columns.userAdmin)
|
.select(columns.userAdmin)
|
||||||
.select(withMetadata)
|
.select(withMetadata)
|
||||||
.$if(!!withPassword, (eb) => eb.select('password'))
|
.$if(!!options?.withPassword, (eb) => eb.select('password'))
|
||||||
.where('email', '=', email)
|
.where('email', '=', email)
|
||||||
.where('users.deletedAt', 'is', null)
|
.where('users.deletedAt', 'is', null)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
@ -4,8 +4,16 @@ export async function up(db: Kysely<any>): Promise<void> {
|
|||||||
const { rows } = await sql<{ db: string }>`SELECT current_database() as db`.execute(db);
|
const { rows } = await sql<{ db: string }>`SELECT current_database() as db`.execute(db);
|
||||||
const databaseName = rows[0].db;
|
const databaseName = rows[0].db;
|
||||||
await sql.raw(`ALTER DATABASE "${databaseName}" SET search_path TO "$user", public, vectors`).execute(db);
|
await sql.raw(`ALTER DATABASE "${databaseName}" SET search_path TO "$user", public, vectors`).execute(db);
|
||||||
await sql`ALTER TABLE "naturalearth_countries" DROP CONSTRAINT IF EXISTS "PK_21a6d86d1ab5d841648212e5353";`.execute(db);
|
const naturalearth_pkey = await sql<{ constraint_name: string }>`SELECT constraint_name
|
||||||
await sql`ALTER TABLE "naturalearth_countries" DROP CONSTRAINT IF EXISTS "naturalearth_countries_pkey";`.execute(db);
|
FROM information_schema.table_constraints
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'naturalearth_countries'
|
||||||
|
AND constraint_type = 'PRIMARY KEY';`.execute(db);
|
||||||
|
const naturalearth_pkey_name = naturalearth_pkey.rows[0]?.constraint_name;
|
||||||
|
if(naturalearth_pkey_name) {
|
||||||
|
await sql`ALTER TABLE "naturalearth_countries"
|
||||||
|
DROP CONSTRAINT ${sql.ref(naturalearth_pkey_name)};`.execute(db);
|
||||||
|
}
|
||||||
await sql`ALTER TABLE "naturalearth_countries" ADD CONSTRAINT "naturalearth_countries_pkey" PRIMARY KEY ("id") WITH (FILLFACTOR = 100);`.execute(db);
|
await sql`ALTER TABLE "naturalearth_countries" ADD CONSTRAINT "naturalearth_countries_pkey" PRIMARY KEY ("id") WITH (FILLFACTOR = 100);`.execute(db);
|
||||||
await sql`DROP INDEX IF EXISTS "IDX_02a43fd0b3c50fb6d7f0cb7282";`.execute(db);
|
await sql`DROP INDEX IF EXISTS "IDX_02a43fd0b3c50fb6d7f0cb7282";`.execute(db);
|
||||||
await sql`DROP INDEX IF EXISTS "IDX_95ad7106dd7b484275443f580f";`.execute(db);
|
await sql`DROP INDEX IF EXISTS "IDX_95ad7106dd7b484275443f580f";`.execute(db);
|
||||||
|
@ -0,0 +1,9 @@
|
|||||||
|
import { Kysely, sql } from 'kysely';
|
||||||
|
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
await sql`ALTER TABLE "users" ADD "pinCode" character varying;`.execute(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
await sql`ALTER TABLE "users" DROP COLUMN "pinCode";`.execute(db);
|
||||||
|
}
|
@ -16,7 +16,7 @@ export class SharedLinkTable {
|
|||||||
userId!: string;
|
userId!: string;
|
||||||
|
|
||||||
@Column({ type: 'bytea', indexName: 'IDX_sharedlink_key' })
|
@Column({ type: 'bytea', indexName: 'IDX_sharedlink_key' })
|
||||||
key!: Buffer; // use to access the inidividual asset
|
key!: Buffer; // use to access the individual asset
|
||||||
|
|
||||||
@Column()
|
@Column()
|
||||||
type!: SharedLinkType;
|
type!: SharedLinkType;
|
||||||
|
@ -37,6 +37,9 @@ export class UserTable {
|
|||||||
@Column({ default: '' })
|
@Column({ default: '' })
|
||||||
password!: Generated<string>;
|
password!: Generated<string>;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
pinCode!: string | null;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
createdAt!: Generated<Timestamp>;
|
createdAt!: Generated<Timestamp>;
|
||||||
|
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
import { SALT_ROUNDS } from 'src/constants';
|
||||||
import { UserAdmin } from 'src/database';
|
import { UserAdmin } from 'src/database';
|
||||||
import { AuthDto, SignUpDto } from 'src/dtos/auth.dto';
|
import { AuthDto, SignUpDto } from 'src/dtos/auth.dto';
|
||||||
import { AuthType, Permission } from 'src/enum';
|
import { AuthType, Permission } from 'src/enum';
|
||||||
@ -118,7 +119,7 @@ describe(AuthService.name, () => {
|
|||||||
|
|
||||||
await sut.changePassword(auth, dto);
|
await sut.changePassword(auth, dto);
|
||||||
|
|
||||||
expect(mocks.user.getByEmail).toHaveBeenCalledWith(auth.user.email, true);
|
expect(mocks.user.getByEmail).toHaveBeenCalledWith(auth.user.email, { withPassword: true });
|
||||||
expect(mocks.crypto.compareBcrypt).toHaveBeenCalledWith('old-password', 'hash-password');
|
expect(mocks.crypto.compareBcrypt).toHaveBeenCalledWith('old-password', 'hash-password');
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -859,4 +860,77 @@ describe(AuthService.name, () => {
|
|||||||
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: '' });
|
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: '' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('setupPinCode', () => {
|
||||||
|
it('should setup a PIN code', async () => {
|
||||||
|
const user = factory.userAdmin();
|
||||||
|
const auth = factory.auth({ user });
|
||||||
|
const dto = { pinCode: '123456' };
|
||||||
|
|
||||||
|
mocks.user.getForPinCode.mockResolvedValue({ pinCode: null, password: '' });
|
||||||
|
mocks.user.update.mockResolvedValue(user);
|
||||||
|
|
||||||
|
await sut.setupPinCode(auth, dto);
|
||||||
|
|
||||||
|
expect(mocks.user.getForPinCode).toHaveBeenCalledWith(user.id);
|
||||||
|
expect(mocks.crypto.hashBcrypt).toHaveBeenCalledWith('123456', SALT_ROUNDS);
|
||||||
|
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { pinCode: expect.any(String) });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail if the user already has a PIN code', async () => {
|
||||||
|
const user = factory.userAdmin();
|
||||||
|
const auth = factory.auth({ user });
|
||||||
|
|
||||||
|
mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
|
||||||
|
|
||||||
|
await expect(sut.setupPinCode(auth, { pinCode: '123456' })).rejects.toThrow('User already has a PIN code');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('changePinCode', () => {
|
||||||
|
it('should change the PIN code', async () => {
|
||||||
|
const user = factory.userAdmin();
|
||||||
|
const auth = factory.auth({ user });
|
||||||
|
const dto = { pinCode: '123456', newPinCode: '012345' };
|
||||||
|
|
||||||
|
mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
|
||||||
|
mocks.user.update.mockResolvedValue(user);
|
||||||
|
mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b);
|
||||||
|
|
||||||
|
await sut.changePinCode(auth, dto);
|
||||||
|
|
||||||
|
expect(mocks.crypto.compareBcrypt).toHaveBeenCalledWith('123456', '123456 (hashed)');
|
||||||
|
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { pinCode: '012345 (hashed)' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail if the PIN code does not match', async () => {
|
||||||
|
const user = factory.userAdmin();
|
||||||
|
mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
|
||||||
|
mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
sut.changePinCode(factory.auth({ user }), { pinCode: '000000', newPinCode: '012345' }),
|
||||||
|
).rejects.toThrow('Wrong PIN code');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resetPinCode', () => {
|
||||||
|
it('should reset the PIN code', async () => {
|
||||||
|
const user = factory.userAdmin();
|
||||||
|
mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
|
||||||
|
mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b);
|
||||||
|
|
||||||
|
await sut.resetPinCode(factory.auth({ user }), { pinCode: '123456' });
|
||||||
|
|
||||||
|
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { pinCode: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw if the PIN code does not match', async () => {
|
||||||
|
const user = factory.userAdmin();
|
||||||
|
mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
|
||||||
|
mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b);
|
||||||
|
|
||||||
|
await expect(sut.resetPinCode(factory.auth({ user }), { pinCode: '000000' })).rejects.toThrow('Wrong PIN code');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -9,11 +9,15 @@ import { StorageCore } from 'src/cores/storage.core';
|
|||||||
import { UserAdmin } from 'src/database';
|
import { UserAdmin } from 'src/database';
|
||||||
import {
|
import {
|
||||||
AuthDto,
|
AuthDto,
|
||||||
|
AuthStatusResponseDto,
|
||||||
ChangePasswordDto,
|
ChangePasswordDto,
|
||||||
LoginCredentialDto,
|
LoginCredentialDto,
|
||||||
LogoutResponseDto,
|
LogoutResponseDto,
|
||||||
OAuthCallbackDto,
|
OAuthCallbackDto,
|
||||||
OAuthConfigDto,
|
OAuthConfigDto,
|
||||||
|
PinCodeChangeDto,
|
||||||
|
PinCodeResetDto,
|
||||||
|
PinCodeSetupDto,
|
||||||
SignUpDto,
|
SignUpDto,
|
||||||
mapLoginResponse,
|
mapLoginResponse,
|
||||||
} from 'src/dtos/auth.dto';
|
} from 'src/dtos/auth.dto';
|
||||||
@ -56,9 +60,9 @@ export class AuthService extends BaseService {
|
|||||||
throw new UnauthorizedException('Password login has been disabled');
|
throw new UnauthorizedException('Password login has been disabled');
|
||||||
}
|
}
|
||||||
|
|
||||||
let user = await this.userRepository.getByEmail(dto.email, true);
|
let user = await this.userRepository.getByEmail(dto.email, { withPassword: true });
|
||||||
if (user) {
|
if (user) {
|
||||||
const isAuthenticated = this.validatePassword(dto.password, user);
|
const isAuthenticated = this.validateSecret(dto.password, user.password);
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
user = undefined;
|
user = undefined;
|
||||||
}
|
}
|
||||||
@ -86,12 +90,12 @@ export class AuthService extends BaseService {
|
|||||||
|
|
||||||
async changePassword(auth: AuthDto, dto: ChangePasswordDto): Promise<UserAdminResponseDto> {
|
async changePassword(auth: AuthDto, dto: ChangePasswordDto): Promise<UserAdminResponseDto> {
|
||||||
const { password, newPassword } = dto;
|
const { password, newPassword } = dto;
|
||||||
const user = await this.userRepository.getByEmail(auth.user.email, true);
|
const user = await this.userRepository.getByEmail(auth.user.email, { withPassword: true });
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
const valid = this.validatePassword(password, user);
|
const valid = this.validateSecret(password, user.password);
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
throw new BadRequestException('Wrong password');
|
throw new BadRequestException('Wrong password');
|
||||||
}
|
}
|
||||||
@ -103,6 +107,56 @@ export class AuthService extends BaseService {
|
|||||||
return mapUserAdmin(updatedUser);
|
return mapUserAdmin(updatedUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setupPinCode(auth: AuthDto, { pinCode }: PinCodeSetupDto) {
|
||||||
|
const user = await this.userRepository.getForPinCode(auth.user.id);
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.pinCode) {
|
||||||
|
throw new BadRequestException('User already has a PIN code');
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashed = await this.cryptoRepository.hashBcrypt(pinCode, SALT_ROUNDS);
|
||||||
|
await this.userRepository.update(auth.user.id, { pinCode: hashed });
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetPinCode(auth: AuthDto, dto: PinCodeResetDto) {
|
||||||
|
const user = await this.userRepository.getForPinCode(auth.user.id);
|
||||||
|
this.resetPinChecks(user, dto);
|
||||||
|
|
||||||
|
await this.userRepository.update(auth.user.id, { pinCode: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
async changePinCode(auth: AuthDto, dto: PinCodeChangeDto) {
|
||||||
|
const user = await this.userRepository.getForPinCode(auth.user.id);
|
||||||
|
this.resetPinChecks(user, dto);
|
||||||
|
|
||||||
|
const hashed = await this.cryptoRepository.hashBcrypt(dto.newPinCode, SALT_ROUNDS);
|
||||||
|
await this.userRepository.update(auth.user.id, { pinCode: hashed });
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetPinChecks(
|
||||||
|
user: { pinCode: string | null; password: string | null },
|
||||||
|
dto: { pinCode?: string; password?: string },
|
||||||
|
) {
|
||||||
|
if (!user.pinCode) {
|
||||||
|
throw new BadRequestException('User does not have a PIN code');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.password) {
|
||||||
|
if (!this.validateSecret(dto.password, user.password)) {
|
||||||
|
throw new BadRequestException('Wrong password');
|
||||||
|
}
|
||||||
|
} else if (dto.pinCode) {
|
||||||
|
if (!this.validateSecret(dto.pinCode, user.pinCode)) {
|
||||||
|
throw new BadRequestException('Wrong PIN code');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new BadRequestException('Either password or pinCode is required');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async adminSignUp(dto: SignUpDto): Promise<UserAdminResponseDto> {
|
async adminSignUp(dto: SignUpDto): Promise<UserAdminResponseDto> {
|
||||||
const adminUser = await this.userRepository.getAdmin();
|
const adminUser = await this.userRepository.getAdmin();
|
||||||
if (adminUser) {
|
if (adminUser) {
|
||||||
@ -371,11 +425,12 @@ export class AuthService extends BaseService {
|
|||||||
throw new UnauthorizedException('Invalid API key');
|
throw new UnauthorizedException('Invalid API key');
|
||||||
}
|
}
|
||||||
|
|
||||||
private validatePassword(inputPassword: string, user: { password?: string }): boolean {
|
private validateSecret(inputSecret: string, existingHash?: string | null): boolean {
|
||||||
if (!user || !user.password) {
|
if (!existingHash) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return this.cryptoRepository.compareBcrypt(inputPassword, user.password);
|
|
||||||
|
return this.cryptoRepository.compareBcrypt(inputSecret, existingHash);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async validateSession(tokenValue: string): Promise<AuthDto> {
|
private async validateSession(tokenValue: string): Promise<AuthDto> {
|
||||||
@ -428,4 +483,16 @@ export class AuthService extends BaseService {
|
|||||||
}
|
}
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getAuthStatus(auth: AuthDto): Promise<AuthStatusResponseDto> {
|
||||||
|
const user = await this.userRepository.getForPinCode(auth.user.id);
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
pinCode: !!user.pinCode,
|
||||||
|
password: !!user.password,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -70,6 +70,10 @@ export class UserAdminService extends BaseService {
|
|||||||
dto.password = await this.cryptoRepository.hashBcrypt(dto.password, SALT_ROUNDS);
|
dto.password = await this.cryptoRepository.hashBcrypt(dto.password, SALT_ROUNDS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (dto.pinCode) {
|
||||||
|
dto.pinCode = await this.cryptoRepository.hashBcrypt(dto.pinCode, SALT_ROUNDS);
|
||||||
|
}
|
||||||
|
|
||||||
if (dto.storageLabel === '') {
|
if (dto.storageLabel === '') {
|
||||||
dto.storageLabel = null;
|
dto.storageLabel = null;
|
||||||
}
|
}
|
||||||
|
@ -18,6 +18,7 @@ import {
|
|||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
IsUUID,
|
IsUUID,
|
||||||
|
Matches,
|
||||||
Validate,
|
Validate,
|
||||||
ValidateBy,
|
ValidateBy,
|
||||||
ValidateIf,
|
ValidateIf,
|
||||||
@ -70,6 +71,22 @@ export class UUIDParamDto {
|
|||||||
id!: string;
|
id!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PinCodeOptions = { optional?: boolean } & OptionalOptions;
|
||||||
|
export const PinCode = ({ optional, ...options }: PinCodeOptions = {}) => {
|
||||||
|
const decorators = [
|
||||||
|
IsString(),
|
||||||
|
IsNotEmpty(),
|
||||||
|
Matches(/^\d{6}$/, { message: ({ property }) => `${property} must be a 6-digit numeric string` }),
|
||||||
|
ApiProperty({ example: '123456' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (optional) {
|
||||||
|
decorators.push(Optional(options));
|
||||||
|
}
|
||||||
|
|
||||||
|
return applyDecorators(...decorators);
|
||||||
|
};
|
||||||
|
|
||||||
export interface OptionalOptions extends ValidationOptions {
|
export interface OptionalOptions extends ValidationOptions {
|
||||||
nullable?: boolean;
|
nullable?: boolean;
|
||||||
/** convert empty strings to null */
|
/** convert empty strings to null */
|
||||||
|
1307
web/package-lock.json
generated
1307
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -59,7 +59,7 @@
|
|||||||
"@faker-js/faker": "^9.3.0",
|
"@faker-js/faker": "^9.3.0",
|
||||||
"@socket.io/component-emitter": "^3.1.0",
|
"@socket.io/component-emitter": "^3.1.0",
|
||||||
"@sveltejs/adapter-static": "^3.0.8",
|
"@sveltejs/adapter-static": "^3.0.8",
|
||||||
"@sveltejs/enhanced-img": "^0.4.4",
|
"@sveltejs/enhanced-img": "^0.5.0",
|
||||||
"@sveltejs/kit": "^2.15.2",
|
"@sveltejs/kit": "^2.15.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||||
"@testing-library/jest-dom": "^6.4.2",
|
"@testing-library/jest-dom": "^6.4.2",
|
||||||
@ -75,7 +75,7 @@
|
|||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
"eslint-config-prettier": "^10.0.0",
|
"eslint-config-prettier": "^10.0.0",
|
||||||
"eslint-p": "^0.21.0",
|
"eslint-p": "^0.22.0",
|
||||||
"eslint-plugin-svelte": "^3.0.0",
|
"eslint-plugin-svelte": "^3.0.0",
|
||||||
"eslint-plugin-unicorn": "^57.0.0",
|
"eslint-plugin-unicorn": "^57.0.0",
|
||||||
"factory.ts": "^1.4.1",
|
"factory.ts": "^1.4.1",
|
||||||
|
@ -38,7 +38,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="text-sm dark:text-immich-dark-fg">{$t('total_usage').toUpperCase()}</p>
|
<p class="text-sm dark:text-immich-dark-fg">{$t('total_usage').toUpperCase()}</p>
|
||||||
|
|
||||||
<div class="mt-5 hidden justify-between lg:flex">
|
<div class="mt-5 hidden justify-between lg:flex gap-4">
|
||||||
<StatsCard icon={mdiCameraIris} title={$t('photos').toUpperCase()} value={stats.photos} />
|
<StatsCard icon={mdiCameraIris} title={$t('photos').toUpperCase()} value={stats.photos} />
|
||||||
<StatsCard icon={mdiPlayCircle} title={$t('videos').toUpperCase()} value={stats.videos} />
|
<StatsCard icon={mdiPlayCircle} title={$t('videos').toUpperCase()} value={stats.videos} />
|
||||||
<StatsCard icon={mdiChartPie} title={$t('storage').toUpperCase()} value={statsUsage} unit={statsUsageUnit} />
|
<StatsCard icon={mdiChartPie} title={$t('storage').toUpperCase()} value={statsUsage} unit={statsUsageUnit} />
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Icon from '$lib/components/elements/icon.svelte';
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
import { ByteUnit } from '$lib/utils/byte-units';
|
import { ByteUnit } from '$lib/utils/byte-units';
|
||||||
|
import { Code, Text } from '@immich/ui';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
icon: string;
|
icon: string;
|
||||||
@ -20,18 +21,16 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-[140px] w-[250px] flex-col justify-between rounded-3xl bg-subtle p-5 dark:bg-immich-dark-gray">
|
<div class="flex h-[140px] w-full flex-col justify-between rounded-3xl bg-subtle text-primary p-5">
|
||||||
<div class="flex place-items-center gap-4 text-immich-primary dark:text-immich-dark-primary">
|
<div class="flex place-items-center gap-4">
|
||||||
<Icon path={icon} size="40" />
|
<Icon path={icon} size="40" />
|
||||||
<p>{title}</p>
|
<Text size="large" fontWeight="bold">{title}</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="relative text-center font-mono text-2xl font-semibold">
|
<div class="relative mx-auto font-mono text-2xl font-semibold">
|
||||||
<span class="text-[#DCDADA] dark:text-[#525252]">{zeros()}</span><span
|
<span class="text-gray-400 dark:text-gray-600">{zeros()}</span><span>{value}</span>
|
||||||
class="text-immich-primary dark:text-immich-dark-primary">{value}</span
|
|
||||||
>
|
|
||||||
{#if unit}
|
{#if unit}
|
||||||
<span class="absolute -top-5 end-2 text-base font-light text-gray-400">{unit}</span>
|
<Code color="muted" class="absolute -top-5 end-2 font-light">{unit}</Code>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -131,6 +131,7 @@
|
|||||||
bind:mapMarkers
|
bind:mapMarkers
|
||||||
onSelect={onViewAssets}
|
onSelect={onViewAssets}
|
||||||
showSettings={false}
|
showSettings={false}
|
||||||
|
rounded
|
||||||
/>
|
/>
|
||||||
{/await}
|
{/await}
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,43 +1,46 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, type Snippet } from 'svelte';
|
import { goto } from '$app/navigation';
|
||||||
import { groupBy } from 'lodash-es';
|
import AlbumCardGroup from '$lib/components/album-page/album-card-group.svelte';
|
||||||
import { addUsersToAlbum, deleteAlbum, type AlbumUserAddDto, type AlbumResponseDto, isHttpError } from '@immich/sdk';
|
import AlbumsTable from '$lib/components/album-page/albums-table.svelte';
|
||||||
import { mdiDeleteOutline, mdiShareVariantOutline, mdiFolderDownloadOutline, mdiRenameOutline } from '@mdi/js';
|
|
||||||
import EditAlbumForm from '$lib/components/forms/edit-album-form.svelte';
|
import EditAlbumForm from '$lib/components/forms/edit-album-form.svelte';
|
||||||
import CreateSharedLinkModal from '$lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte';
|
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||||
|
import RightClickContextMenu from '$lib/components/shared-components/context-menu/right-click-context-menu.svelte';
|
||||||
import {
|
import {
|
||||||
NotificationType,
|
NotificationType,
|
||||||
notificationController,
|
notificationController,
|
||||||
} from '$lib/components/shared-components/notification/notification';
|
} from '$lib/components/shared-components/notification/notification';
|
||||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
import { AppRoute } from '$lib/constants';
|
||||||
import RightClickContextMenu from '$lib/components/shared-components/context-menu/right-click-context-menu.svelte';
|
import { modalManager } from '$lib/managers/modal-manager.svelte';
|
||||||
import AlbumsTable from '$lib/components/album-page/albums-table.svelte';
|
import AlbumShareModal from '$lib/modals/AlbumShareModal.svelte';
|
||||||
import AlbumCardGroup from '$lib/components/album-page/album-card-group.svelte';
|
import QrCodeModal from '$lib/modals/QrCodeModal.svelte';
|
||||||
import UserSelectionModal from '$lib/components/album-page/user-selection-modal.svelte';
|
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
|
||||||
import { downloadAlbum } from '$lib/utils/asset-utils';
|
|
||||||
import { normalizeSearchString } from '$lib/utils/string-utils';
|
|
||||||
import {
|
|
||||||
getSelectedAlbumGroupOption,
|
|
||||||
type AlbumGroup,
|
|
||||||
confirmAlbumDelete,
|
|
||||||
sortAlbums,
|
|
||||||
stringToSortOrder,
|
|
||||||
} from '$lib/utils/album-utils';
|
|
||||||
import type { ContextMenuPosition } from '$lib/utils/context-menu';
|
|
||||||
import { user } from '$lib/stores/user.store';
|
|
||||||
import {
|
import {
|
||||||
|
AlbumFilter,
|
||||||
AlbumGroupBy,
|
AlbumGroupBy,
|
||||||
AlbumSortBy,
|
AlbumSortBy,
|
||||||
AlbumFilter,
|
|
||||||
AlbumViewMode,
|
AlbumViewMode,
|
||||||
SortOrder,
|
SortOrder,
|
||||||
locale,
|
locale,
|
||||||
type AlbumViewSettings,
|
type AlbumViewSettings,
|
||||||
} from '$lib/stores/preferences.store';
|
} from '$lib/stores/preferences.store';
|
||||||
|
import { user } from '$lib/stores/user.store';
|
||||||
import { userInteraction } from '$lib/stores/user.svelte';
|
import { userInteraction } from '$lib/stores/user.svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { makeSharedLinkUrl } from '$lib/utils';
|
||||||
import { AppRoute } from '$lib/constants';
|
import {
|
||||||
|
confirmAlbumDelete,
|
||||||
|
getSelectedAlbumGroupOption,
|
||||||
|
sortAlbums,
|
||||||
|
stringToSortOrder,
|
||||||
|
type AlbumGroup,
|
||||||
|
} from '$lib/utils/album-utils';
|
||||||
|
import { downloadAlbum } from '$lib/utils/asset-utils';
|
||||||
|
import type { ContextMenuPosition } from '$lib/utils/context-menu';
|
||||||
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
|
import { normalizeSearchString } from '$lib/utils/string-utils';
|
||||||
|
import { addUsersToAlbum, deleteAlbum, isHttpError, type AlbumResponseDto, type AlbumUserAddDto } from '@immich/sdk';
|
||||||
|
import { mdiDeleteOutline, mdiFolderDownloadOutline, mdiRenameOutline, mdiShareVariantOutline } from '@mdi/js';
|
||||||
|
import { groupBy } from 'lodash-es';
|
||||||
|
import { onMount, type Snippet } from 'svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import { run } from 'svelte/legacy';
|
import { run } from 'svelte/legacy';
|
||||||
|
|
||||||
@ -140,8 +143,6 @@
|
|||||||
|
|
||||||
let albumGroupOption: string = $state(AlbumGroupBy.None);
|
let albumGroupOption: string = $state(AlbumGroupBy.None);
|
||||||
|
|
||||||
let showShareByURLModal = $state(false);
|
|
||||||
|
|
||||||
let albumToEdit: AlbumResponseDto | null = $state(null);
|
let albumToEdit: AlbumResponseDto | null = $state(null);
|
||||||
let albumToShare: AlbumResponseDto | null = $state(null);
|
let albumToShare: AlbumResponseDto | null = $state(null);
|
||||||
let albumToDelete: AlbumResponseDto | null = null;
|
let albumToDelete: AlbumResponseDto | null = null;
|
||||||
@ -346,18 +347,31 @@
|
|||||||
updateAlbumInfo(album);
|
updateAlbumInfo(album);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openShareModal = () => {
|
const openShareModal = async () => {
|
||||||
if (!contextMenuTargetAlbum) {
|
if (!contextMenuTargetAlbum) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
albumToShare = contextMenuTargetAlbum;
|
albumToShare = contextMenuTargetAlbum;
|
||||||
closeAlbumContextMenu();
|
closeAlbumContextMenu();
|
||||||
};
|
const result = await modalManager.show(AlbumShareModal, { album: albumToShare });
|
||||||
|
|
||||||
const closeShareModal = () => {
|
switch (result?.action) {
|
||||||
albumToShare = null;
|
case 'sharedUsers': {
|
||||||
showShareByURLModal = false;
|
await handleAddUsers(result.data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'sharedLink': {
|
||||||
|
const sharedLink = await modalManager.show(SharedLinkCreateModal, { albumId: albumToShare.id });
|
||||||
|
|
||||||
|
if (sharedLink) {
|
||||||
|
handleSharedLinkCreated(albumToShare);
|
||||||
|
await modalManager.show(QrCodeModal, { title: $t('view_link'), value: makeSharedLinkUrl(sharedLink.key) });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -419,22 +433,4 @@
|
|||||||
onClose={() => (albumToEdit = null)}
|
onClose={() => (albumToEdit = null)}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Share Modal -->
|
|
||||||
{#if albumToShare}
|
|
||||||
{#if showShareByURLModal}
|
|
||||||
<CreateSharedLinkModal
|
|
||||||
albumId={albumToShare.id}
|
|
||||||
onClose={() => closeShareModal()}
|
|
||||||
onCreated={() => albumToShare && handleSharedLinkCreated(albumToShare)}
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<UserSelectionModal
|
|
||||||
album={albumToShare}
|
|
||||||
onSelect={handleAddUsers}
|
|
||||||
onShare={() => (showShareByURLModal = true)}
|
|
||||||
onClose={() => closeShareModal()}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||||
import CreateSharedLinkModal from '$lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte';
|
import { modalManager } from '$lib/managers/modal-manager.svelte';
|
||||||
import Portal from '$lib/components/shared-components/portal/portal.svelte';
|
import QrCodeModal from '$lib/modals/QrCodeModal.svelte';
|
||||||
|
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
|
||||||
|
import { makeSharedLinkUrl } from '$lib/utils';
|
||||||
import type { AssetResponseDto } from '@immich/sdk';
|
import type { AssetResponseDto } from '@immich/sdk';
|
||||||
import { mdiShareVariantOutline } from '@mdi/js';
|
import { mdiShareVariantOutline } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
@ -12,13 +14,13 @@
|
|||||||
|
|
||||||
let { asset }: Props = $props();
|
let { asset }: Props = $props();
|
||||||
|
|
||||||
let showModal = $state(false);
|
const handleClick = async () => {
|
||||||
|
const sharedLink = await modalManager.show(SharedLinkCreateModal, { assetIds: [asset.id] });
|
||||||
|
|
||||||
|
if (sharedLink) {
|
||||||
|
await modalManager.show(QrCodeModal, { title: $t('view_link'), value: makeSharedLinkUrl(sharedLink.key) });
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<CircleIconButton color="opaque" icon={mdiShareVariantOutline} onclick={() => (showModal = true)} title={$t('share')} />
|
<CircleIconButton color="opaque" icon={mdiShareVariantOutline} onclick={handleClick} title={$t('share')} />
|
||||||
|
|
||||||
{#if showModal}
|
|
||||||
<Portal target="body">
|
|
||||||
<CreateSharedLinkModal assetIds={[asset.id]} onClose={() => (showModal = false)} />
|
|
||||||
</Portal>
|
|
||||||
{/if}
|
|
||||||
|
@ -7,6 +7,7 @@
|
|||||||
import DeleteAction from '$lib/components/asset-viewer/actions/delete-action.svelte';
|
import DeleteAction from '$lib/components/asset-viewer/actions/delete-action.svelte';
|
||||||
import DownloadAction from '$lib/components/asset-viewer/actions/download-action.svelte';
|
import DownloadAction from '$lib/components/asset-viewer/actions/download-action.svelte';
|
||||||
import FavoriteAction from '$lib/components/asset-viewer/actions/favorite-action.svelte';
|
import FavoriteAction from '$lib/components/asset-viewer/actions/favorite-action.svelte';
|
||||||
|
import KeepThisDeleteOthersAction from '$lib/components/asset-viewer/actions/keep-this-delete-others.svelte';
|
||||||
import RestoreAction from '$lib/components/asset-viewer/actions/restore-action.svelte';
|
import RestoreAction from '$lib/components/asset-viewer/actions/restore-action.svelte';
|
||||||
import SetAlbumCoverAction from '$lib/components/asset-viewer/actions/set-album-cover-action.svelte';
|
import SetAlbumCoverAction from '$lib/components/asset-viewer/actions/set-album-cover-action.svelte';
|
||||||
import SetFeaturedPhotoAction from '$lib/components/asset-viewer/actions/set-person-featured-action.svelte';
|
import SetFeaturedPhotoAction from '$lib/components/asset-viewer/actions/set-person-featured-action.svelte';
|
||||||
@ -14,7 +15,6 @@
|
|||||||
import ShareAction from '$lib/components/asset-viewer/actions/share-action.svelte';
|
import ShareAction from '$lib/components/asset-viewer/actions/share-action.svelte';
|
||||||
import ShowDetailAction from '$lib/components/asset-viewer/actions/show-detail-action.svelte';
|
import ShowDetailAction from '$lib/components/asset-viewer/actions/show-detail-action.svelte';
|
||||||
import UnstackAction from '$lib/components/asset-viewer/actions/unstack-action.svelte';
|
import UnstackAction from '$lib/components/asset-viewer/actions/unstack-action.svelte';
|
||||||
import KeepThisDeleteOthersAction from '$lib/components/asset-viewer/actions/keep-this-delete-others.svelte';
|
|
||||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
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 MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||||
@ -22,6 +22,7 @@
|
|||||||
import { user } from '$lib/stores/user.store';
|
import { user } from '$lib/stores/user.store';
|
||||||
import { photoZoomState } from '$lib/stores/zoom-image.store';
|
import { photoZoomState } from '$lib/stores/zoom-image.store';
|
||||||
import { getAssetJobName, getSharedLink } from '$lib/utils';
|
import { getAssetJobName, getSharedLink } from '$lib/utils';
|
||||||
|
import { canCopyImageToClipboard } from '$lib/utils/asset-utils';
|
||||||
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||||
import {
|
import {
|
||||||
AssetJobName,
|
AssetJobName,
|
||||||
@ -45,9 +46,8 @@
|
|||||||
mdiPresentationPlay,
|
mdiPresentationPlay,
|
||||||
mdiUpload,
|
mdiUpload,
|
||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
import { canCopyImageToClipboard } from '$lib/utils/asset-utils';
|
|
||||||
import { t } from 'svelte-i18n';
|
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
asset: AssetResponseDto;
|
asset: AssetResponseDto;
|
||||||
@ -104,7 +104,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="z-[1001] flex h-16 place-items-center justify-between bg-gradient-to-b from-black/40 px-3 transition-transform duration-200"
|
class="flex h-16 place-items-center justify-between bg-gradient-to-b from-black/40 px-3 transition-transform duration-200"
|
||||||
>
|
>
|
||||||
<div class="text-white">
|
<div class="text-white">
|
||||||
{#if showCloseButton}
|
{#if showCloseButton}
|
||||||
|
@ -4,6 +4,7 @@
|
|||||||
import MotionPhotoAction from '$lib/components/asset-viewer/actions/motion-photo-action.svelte';
|
import MotionPhotoAction from '$lib/components/asset-viewer/actions/motion-photo-action.svelte';
|
||||||
import NextAssetAction from '$lib/components/asset-viewer/actions/next-asset-action.svelte';
|
import NextAssetAction from '$lib/components/asset-viewer/actions/next-asset-action.svelte';
|
||||||
import PreviousAssetAction from '$lib/components/asset-viewer/actions/previous-asset-action.svelte';
|
import PreviousAssetAction from '$lib/components/asset-viewer/actions/previous-asset-action.svelte';
|
||||||
|
import AssetViewerNavBar from '$lib/components/asset-viewer/asset-viewer-nav-bar.svelte';
|
||||||
import { AssetAction, ProjectionType } from '$lib/constants';
|
import { AssetAction, ProjectionType } from '$lib/constants';
|
||||||
import { activityManager } from '$lib/managers/activity-manager.svelte';
|
import { activityManager } from '$lib/managers/activity-manager.svelte';
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
@ -34,7 +35,6 @@
|
|||||||
import { NotificationType, notificationController } from '../shared-components/notification/notification';
|
import { NotificationType, notificationController } from '../shared-components/notification/notification';
|
||||||
import ActivityStatus from './activity-status.svelte';
|
import ActivityStatus from './activity-status.svelte';
|
||||||
import ActivityViewer from './activity-viewer.svelte';
|
import ActivityViewer from './activity-viewer.svelte';
|
||||||
import AssetViewerNavBar from './asset-viewer-nav-bar.svelte';
|
|
||||||
import DetailPanel from './detail-panel.svelte';
|
import DetailPanel from './detail-panel.svelte';
|
||||||
import CropArea from './editor/crop-tool/crop-area.svelte';
|
import CropArea from './editor/crop-tool/crop-area.svelte';
|
||||||
import EditorPanel from './editor/editor-panel.svelte';
|
import EditorPanel from './editor/editor-panel.svelte';
|
||||||
@ -379,12 +379,13 @@
|
|||||||
|
|
||||||
<section
|
<section
|
||||||
id="immich-asset-viewer"
|
id="immich-asset-viewer"
|
||||||
class="fixed start-0 top-0 z-[1001] grid size-full grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black"
|
class="fixed start-0 top-0 grid size-full grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black"
|
||||||
use:focusTrap
|
use:focusTrap
|
||||||
|
bind:this={assetViewerHtmlElement}
|
||||||
>
|
>
|
||||||
<!-- Top navigation bar -->
|
<!-- Top navigation bar -->
|
||||||
{#if $slideshowState === SlideshowState.None && !isShowEditor}
|
{#if $slideshowState === SlideshowState.None && !isShowEditor}
|
||||||
<div class="z-[1002] col-span-4 col-start-1 row-span-1 row-start-1 transition-transform">
|
<div class="col-span-4 col-start-1 row-span-1 row-start-1 transition-transform">
|
||||||
<AssetViewerNavBar
|
<AssetViewerNavBar
|
||||||
{asset}
|
{asset}
|
||||||
{album}
|
{album}
|
||||||
@ -412,16 +413,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if $slideshowState === SlideshowState.None && showNavigation && !isShowEditor}
|
|
||||||
<div class="z-[1001] my-auto column-span-1 col-start-1 row-span-full row-start-1 justify-self-start">
|
|
||||||
<PreviousAssetAction onPreviousAsset={() => navigateAsset('previous')} />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Asset Viewer -->
|
|
||||||
<div class="z-[1000] relative col-start-1 col-span-4 row-start-1 row-span-full" bind:this={assetViewerHtmlElement}>
|
|
||||||
{#if $slideshowState != SlideshowState.None}
|
{#if $slideshowState != SlideshowState.None}
|
||||||
<div class="z-[1000] absolute w-full flex">
|
<div class="absolute w-full flex">
|
||||||
<SlideshowBar
|
<SlideshowBar
|
||||||
{isFullScreen}
|
{isFullScreen}
|
||||||
onSetToFullScreen={() => assetViewerHtmlElement?.requestFullscreen?.()}
|
onSetToFullScreen={() => assetViewerHtmlElement?.requestFullscreen?.()}
|
||||||
@ -432,6 +425,14 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if $slideshowState === SlideshowState.None && showNavigation && !isShowEditor}
|
||||||
|
<div class="my-auto column-span-1 col-start-1 row-span-full row-start-1 justify-self-start">
|
||||||
|
<PreviousAssetAction onPreviousAsset={() => navigateAsset('previous')} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Asset Viewer -->
|
||||||
|
<div class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full">
|
||||||
{#if previewStackedAsset}
|
{#if previewStackedAsset}
|
||||||
{#key previewStackedAsset.id}
|
{#key previewStackedAsset.id}
|
||||||
{#if previewStackedAsset.type === AssetTypeEnum.Image}
|
{#if previewStackedAsset.type === AssetTypeEnum.Image}
|
||||||
@ -504,7 +505,7 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{#if $slideshowState === SlideshowState.None && isShared && ((album && album.isActivityEnabled) || activityManager.commentCount > 0)}
|
{#if $slideshowState === SlideshowState.None && isShared && ((album && album.isActivityEnabled) || activityManager.commentCount > 0)}
|
||||||
<div class="z-[9999] absolute bottom-0 end-0 mb-20 me-8">
|
<div class="absolute bottom-0 end-0 mb-20 me-8">
|
||||||
<ActivityStatus
|
<ActivityStatus
|
||||||
disabled={!album?.isActivityEnabled}
|
disabled={!album?.isActivityEnabled}
|
||||||
isLiked={activityManager.isLiked}
|
isLiked={activityManager.isLiked}
|
||||||
@ -519,7 +520,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if $slideshowState === SlideshowState.None && showNavigation && !isShowEditor}
|
{#if $slideshowState === SlideshowState.None && showNavigation && !isShowEditor}
|
||||||
<div class="z-[1001] my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end">
|
<div class="my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end">
|
||||||
<NextAssetAction onNextAsset={() => navigateAsset('next')} />
|
<NextAssetAction onNextAsset={() => navigateAsset('next')} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@ -528,7 +529,7 @@
|
|||||||
<div
|
<div
|
||||||
transition:fly={{ duration: 150 }}
|
transition:fly={{ duration: 150 }}
|
||||||
id="detail-panel"
|
id="detail-panel"
|
||||||
class="z-[1002] row-start-1 row-span-4 w-[360px] overflow-y-auto bg-immich-bg transition-all dark:border-l dark:border-s-immich-dark-gray dark:bg-immich-dark-bg"
|
class="row-start-1 row-span-4 w-[360px] overflow-y-auto bg-immich-bg transition-all dark:border-l dark:border-s-immich-dark-gray dark:bg-immich-dark-bg"
|
||||||
translate="yes"
|
translate="yes"
|
||||||
>
|
>
|
||||||
<DetailPanel {asset} currentAlbum={album} albums={appearsInAlbums} onClose={() => ($isShowDetail = false)} />
|
<DetailPanel {asset} currentAlbum={album} albums={appearsInAlbums} onClose={() => ($isShowDetail = false)} />
|
||||||
@ -539,7 +540,7 @@
|
|||||||
<div
|
<div
|
||||||
transition:fly={{ duration: 150 }}
|
transition:fly={{ duration: 150 }}
|
||||||
id="editor-panel"
|
id="editor-panel"
|
||||||
class="z-[1002] row-start-1 row-span-4 w-[400px] overflow-y-auto bg-immich-bg transition-all dark:border-l dark:border-s-immich-dark-gray dark:bg-immich-dark-bg"
|
class="row-start-1 row-span-4 w-[400px] overflow-y-auto bg-immich-bg transition-all dark:border-l dark:border-s-immich-dark-gray dark:bg-immich-dark-bg"
|
||||||
translate="yes"
|
translate="yes"
|
||||||
>
|
>
|
||||||
<EditorPanel {asset} onUpdateSelectedType={handleUpdateSelectedEditType} onClose={closeEditor} />
|
<EditorPanel {asset} onUpdateSelectedType={handleUpdateSelectedEditType} onClose={closeEditor} />
|
||||||
@ -550,7 +551,7 @@
|
|||||||
{@const stackedAssets = stack.assets}
|
{@const stackedAssets = stack.assets}
|
||||||
<div
|
<div
|
||||||
id="stack-slideshow"
|
id="stack-slideshow"
|
||||||
class="z-[1002] flex place-item-center place-content-center absolute bottom-0 w-full col-span-4 col-start-1 overflow-x-auto horizontal-scrollbar"
|
class="flex place-item-center place-content-center absolute bottom-0 w-full col-span-4 col-start-1 overflow-x-auto horizontal-scrollbar"
|
||||||
>
|
>
|
||||||
<div class="relative w-full">
|
<div class="relative w-full">
|
||||||
{#each stackedAssets as stackedAsset (stackedAsset.id)}
|
{#each stackedAssets as stackedAsset (stackedAsset.id)}
|
||||||
@ -588,7 +589,7 @@
|
|||||||
<div
|
<div
|
||||||
transition:fly={{ duration: 150 }}
|
transition:fly={{ duration: 150 }}
|
||||||
id="activity-panel"
|
id="activity-panel"
|
||||||
class="z-[1002] row-start-1 row-span-5 w-[360px] md:w-[460px] overflow-y-auto bg-immich-bg transition-all dark:border-l dark:border-s-immich-dark-gray dark:bg-immich-dark-bg"
|
class="row-start-1 row-span-5 w-[360px] md:w-[460px] overflow-y-auto bg-immich-bg transition-all dark:border-l dark:border-s-immich-dark-gray dark:bg-immich-dark-bg"
|
||||||
translate="yes"
|
translate="yes"
|
||||||
>
|
>
|
||||||
<ActivityViewer
|
<ActivityViewer
|
||||||
|
@ -6,7 +6,6 @@
|
|||||||
import DetailPanelTags from '$lib/components/asset-viewer/detail-panel-tags.svelte';
|
import DetailPanelTags from '$lib/components/asset-viewer/detail-panel-tags.svelte';
|
||||||
import Icon from '$lib/components/elements/icon.svelte';
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
import ChangeDate from '$lib/components/shared-components/change-date.svelte';
|
import ChangeDate from '$lib/components/shared-components/change-date.svelte';
|
||||||
import Portal from '$lib/components/shared-components/portal/portal.svelte';
|
|
||||||
import { AppRoute, QueryParameter, timeToLoadTheMap } from '$lib/constants';
|
import { AppRoute, QueryParameter, timeToLoadTheMap } from '$lib/constants';
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||||
@ -355,14 +354,12 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if isShowChangeDate}
|
{#if isShowChangeDate}
|
||||||
<Portal>
|
|
||||||
<ChangeDate
|
<ChangeDate
|
||||||
initialDate={dateTime}
|
initialDate={dateTime}
|
||||||
initialTimeZone={timeZone ?? ''}
|
initialTimeZone={timeZone ?? ''}
|
||||||
onConfirm={handleConfirmChangeDate}
|
onConfirm={handleConfirmChangeDate}
|
||||||
onCancel={() => (isShowChangeDate = false)}
|
onCancel={() => (isShowChangeDate = false)}
|
||||||
/>
|
/>
|
||||||
</Portal>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="flex gap-4 py-4">
|
<div class="flex gap-4 py-4">
|
||||||
|
@ -1,27 +1,27 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { shortcuts } from '$lib/actions/shortcut';
|
import { shortcuts } from '$lib/actions/shortcut';
|
||||||
import { zoomImageAction, zoomed } from '$lib/actions/zoom-image';
|
import { zoomImageAction, zoomed } 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 BrokenAsset from '$lib/components/assets/broken-asset.svelte';
|
||||||
|
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
|
||||||
|
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||||
import { boundingBoxesArray } from '$lib/stores/people.store';
|
import { boundingBoxesArray } from '$lib/stores/people.store';
|
||||||
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
|
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
|
||||||
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
|
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
|
||||||
import { photoZoomState } from '$lib/stores/zoom-image.store';
|
import { photoZoomState } from '$lib/stores/zoom-image.store';
|
||||||
import { getAssetOriginalUrl, getAssetThumbnailUrl, handlePromiseError } from '$lib/utils';
|
import { getAssetOriginalUrl, getAssetThumbnailUrl, handlePromiseError } from '$lib/utils';
|
||||||
import { canCopyImageToClipboard, copyImageToClipboard, isWebCompatibleImage } from '$lib/utils/asset-utils';
|
import { canCopyImageToClipboard, copyImageToClipboard, isWebCompatibleImage } from '$lib/utils/asset-utils';
|
||||||
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { getBoundingBox } from '$lib/utils/people-utils';
|
import { getBoundingBox } from '$lib/utils/people-utils';
|
||||||
|
import { cancelImageUrl, preloadImageUrl } from '$lib/utils/sw-messaging';
|
||||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||||
import { AssetMediaSize, AssetTypeEnum, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
|
import { AssetMediaSize, AssetTypeEnum, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
import { swipe, type SwipeCustomEvent } from 'svelte-gestures';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import { type SwipeCustomEvent, swipe } from 'svelte-gestures';
|
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
||||||
import { NotificationType, notificationController } from '../shared-components/notification/notification';
|
import { NotificationType, notificationController } from '../shared-components/notification/notification';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
|
||||||
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
|
|
||||||
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
|
|
||||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
|
||||||
import { cancelImageUrl, preloadImageUrl } from '$lib/utils/sw-messaging';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
asset: AssetResponseDto;
|
asset: AssetResponseDto;
|
||||||
|
@ -1,16 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
|
||||||
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
|
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
|
||||||
|
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||||
import { loopVideo as loopVideoPreference, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
|
import { loopVideo as loopVideoPreference, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
|
||||||
import { getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
|
import { getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { AssetMediaSize } from '@immich/sdk';
|
import { AssetMediaSize } from '@immich/sdk';
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import { swipe } from 'svelte-gestures';
|
|
||||||
import type { SwipeCustomEvent } from 'svelte-gestures';
|
import type { SwipeCustomEvent } from 'svelte-gestures';
|
||||||
import { fade } from 'svelte/transition';
|
import { swipe } from 'svelte-gestures';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
import { fade } from 'svelte/transition';
|
||||||
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
assetId: string;
|
assetId: string;
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getAssetPlaybackUrl, getAssetOriginalUrl } from '$lib/utils';
|
import { getAssetOriginalUrl, getAssetPlaybackUrl } from '$lib/utils';
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
||||||
import { t } from 'svelte-i18n';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
assetId: string;
|
assetId: string;
|
||||||
|
@ -41,7 +41,7 @@ describe('Thumbnail component', () => {
|
|||||||
expect(container).not.toBeNull();
|
expect(container).not.toBeNull();
|
||||||
expect(container!.getAttribute('tabindex')).toBe('0');
|
expect(container!.getAttribute('tabindex')).toBe('0');
|
||||||
|
|
||||||
// Guarding against inserting extra tabbable elments in future in <Thumbnail/>
|
// Guarding against inserting extra tabbable elements in future in <Thumbnail/>
|
||||||
const tabbables = getTabbable(container!);
|
const tabbables = getTabbable(container!);
|
||||||
expect(tabbables.length).toBe(0);
|
expect(tabbables.length).toBe(0);
|
||||||
});
|
});
|
||||||
|
@ -51,7 +51,10 @@
|
|||||||
</header>
|
</header>
|
||||||
<div
|
<div
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
class="relative grid h-[calc(100dvh-var(--navbar-height))] grid-cols-[theme(spacing.0)_auto] overflow-hidden bg-immich-bg dark:bg-immich-dark-bg sidebar:grid-cols-[theme(spacing.64)_auto]"
|
class="relative grid grid-cols-[theme(spacing.0)_auto] overflow-hidden bg-immich-bg dark:bg-immich-dark-bg sidebar:grid-cols-[theme(spacing.64)_auto]
|
||||||
|
{hideNavbar ? 'h-dvh' : 'h-[calc(100dvh-var(--navbar-height))]'}
|
||||||
|
{hideNavbar ? 'pt-[var(--navbar-height)]' : ''}
|
||||||
|
{hideNavbar ? 'max-md:pt-[var(--navbar-height-md)]' : ''}"
|
||||||
>
|
>
|
||||||
{#if sidebar}{@render sidebar()}{:else if admin}
|
{#if sidebar}{@render sidebar()}{:else if admin}
|
||||||
<AdminSideBar />
|
<AdminSideBar />
|
||||||
|
@ -123,7 +123,7 @@
|
|||||||
await progressBarController.set(1);
|
await progressBarController.set(1);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// this may happen if browser blocks auto-play of the video on first page load. This can either be a setting
|
// this may happen if browser blocks auto-play of the video on first page load. This can either be a setting
|
||||||
// or just defaut in certain browsers on page load without any DOM interaction by user.
|
// or just default in certain browsers on page load without any DOM interaction by user.
|
||||||
console.error(`handleAction[${callingContext}] videoPlayer play problem: ${error}`);
|
console.error(`handleAction[${callingContext}] videoPlayer play problem: ${error}`);
|
||||||
paused = true;
|
paused = true;
|
||||||
await progressBarController.set(0);
|
await progressBarController.set(0);
|
||||||
|
@ -1,16 +1,24 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||||
import CreateSharedLinkModal from '$lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte';
|
import { getAssetControlContext } from '$lib/components/photos-page/asset-select-control-bar.svelte';
|
||||||
|
import { modalManager } from '$lib/managers/modal-manager.svelte';
|
||||||
|
import QrCodeModal from '$lib/modals/QrCodeModal.svelte';
|
||||||
|
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
|
||||||
|
import { makeSharedLinkUrl } from '$lib/utils';
|
||||||
import { mdiShareVariantOutline } from '@mdi/js';
|
import { mdiShareVariantOutline } from '@mdi/js';
|
||||||
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
|
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
let showModal = $state(false);
|
|
||||||
const { getAssets } = getAssetControlContext();
|
const { getAssets } = getAssetControlContext();
|
||||||
|
|
||||||
|
const handleClick = async () => {
|
||||||
|
const sharedLink = await modalManager.show(SharedLinkCreateModal, {
|
||||||
|
assetIds: [...getAssets()].map(({ id }) => id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sharedLink) {
|
||||||
|
await modalManager.show(QrCodeModal, { title: $t('view_link'), value: makeSharedLinkUrl(sharedLink.key) });
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<CircleIconButton title={$t('share')} icon={mdiShareVariantOutline} onclick={() => (showModal = true)} />
|
<CircleIconButton title={$t('share')} icon={mdiShareVariantOutline} onclick={handleClick} />
|
||||||
|
|
||||||
{#if showModal}
|
|
||||||
<CreateSharedLinkModal assetIds={[...getAssets()].map(({ id }) => id)} onClose={() => (showModal = false)} />
|
|
||||||
{/if}
|
|
||||||
|
@ -12,6 +12,8 @@
|
|||||||
import SelectDate from '$lib/components/shared-components/select-date.svelte';
|
import SelectDate from '$lib/components/shared-components/select-date.svelte';
|
||||||
import { AppRoute, AssetAction } from '$lib/constants';
|
import { AppRoute, AssetAction } from '$lib/constants';
|
||||||
import { albumMapViewManager } from '$lib/managers/album-view-map.manager.svelte';
|
import { albumMapViewManager } from '$lib/managers/album-view-map.manager.svelte';
|
||||||
|
import { modalManager } from '$lib/managers/modal-manager.svelte';
|
||||||
|
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
|
||||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import { AssetBucket, assetsSnapshot, AssetStore, isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
|
import { AssetBucket, assetsSnapshot, AssetStore, isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
|
||||||
@ -31,7 +33,6 @@
|
|||||||
import type { UpdatePayload } from 'vite';
|
import type { UpdatePayload } from 'vite';
|
||||||
import Portal from '../shared-components/portal/portal.svelte';
|
import Portal from '../shared-components/portal/portal.svelte';
|
||||||
import Scrubber from '../shared-components/scrubber/scrubber.svelte';
|
import Scrubber from '../shared-components/scrubber/scrubber.svelte';
|
||||||
import ShowShortcuts from '../shared-components/show-shortcuts.svelte';
|
|
||||||
import AssetDateGroup from './asset-date-group.svelte';
|
import AssetDateGroup from './asset-date-group.svelte';
|
||||||
import DeleteAssetDialog from './delete-asset-dialog.svelte';
|
import DeleteAssetDialog from './delete-asset-dialog.svelte';
|
||||||
|
|
||||||
@ -81,7 +82,6 @@
|
|||||||
let element: HTMLElement | undefined = $state();
|
let element: HTMLElement | undefined = $state();
|
||||||
|
|
||||||
let timelineElement: HTMLElement | undefined = $state();
|
let timelineElement: HTMLElement | undefined = $state();
|
||||||
let showShortcuts = $state(false);
|
|
||||||
let showSkeleton = $state(true);
|
let showSkeleton = $state(true);
|
||||||
let isShowSelectDate = $state(false);
|
let isShowSelectDate = $state(false);
|
||||||
let scrubBucketPercent = $state(0);
|
let scrubBucketPercent = $state(0);
|
||||||
@ -317,7 +317,7 @@
|
|||||||
// allowing next to be at least 1 may cause percent to go negative, so ensure positive percentage
|
// allowing next to be at least 1 may cause percent to go negative, so ensure positive percentage
|
||||||
scrubBucketPercent = Math.max(0, top / (bucketHeight * maxScrollPercent));
|
scrubBucketPercent = Math.max(0, top / (bucketHeight * maxScrollPercent));
|
||||||
|
|
||||||
// compensate for lost precision/rouding errors advance to the next bucket, if present
|
// compensate for lost precision/rounding errors advance to the next bucket, if present
|
||||||
if (scrubBucketPercent > 0.9999 && i + 1 < bucketsLength - 1) {
|
if (scrubBucketPercent > 0.9999 && i + 1 < bucketsLength - 1) {
|
||||||
scrubBucket = assetStore.buckets[i + 1];
|
scrubBucket = assetStore.buckets[i + 1];
|
||||||
scrubBucketPercent = 0;
|
scrubBucketPercent = 0;
|
||||||
@ -635,6 +635,17 @@
|
|||||||
let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
|
let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
|
||||||
let isEmpty = $derived(assetStore.isInitialized && assetStore.buckets.length === 0);
|
let isEmpty = $derived(assetStore.isInitialized && assetStore.buckets.length === 0);
|
||||||
let idsSelectedAssets = $derived(assetInteraction.selectedAssets.map(({ id }) => id));
|
let idsSelectedAssets = $derived(assetInteraction.selectedAssets.map(({ id }) => id));
|
||||||
|
let isShortcutModalOpen = false;
|
||||||
|
|
||||||
|
const handleOpenShortcutModal = async () => {
|
||||||
|
if (isShortcutModalOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isShortcutModalOpen = true;
|
||||||
|
await modalManager.show(ShortcutsModal, {});
|
||||||
|
isShortcutModalOpen = false;
|
||||||
|
};
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (isEmpty) {
|
if (isEmpty) {
|
||||||
@ -653,7 +664,7 @@
|
|||||||
|
|
||||||
const shortcuts: ShortcutOptions[] = [
|
const shortcuts: ShortcutOptions[] = [
|
||||||
{ shortcut: { key: 'Escape' }, onShortcut: onEscape },
|
{ shortcut: { key: 'Escape' }, onShortcut: onEscape },
|
||||||
{ shortcut: { key: '?', shift: true }, onShortcut: () => (showShortcuts = !showShortcuts) },
|
{ shortcut: { key: '?', shift: true }, onShortcut: handleOpenShortcutModal },
|
||||||
{ shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) },
|
{ shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) },
|
||||||
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets(assetStore, assetInteraction) },
|
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets(assetStore, assetInteraction) },
|
||||||
{ shortcut: { key: 'ArrowRight' }, onShortcut: focusNextAsset },
|
{ shortcut: { key: 'ArrowRight' }, onShortcut: focusNextAsset },
|
||||||
@ -710,10 +721,6 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showShortcuts}
|
|
||||||
<ShowShortcuts onClose={() => (showShortcuts = !showShortcuts)} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if isShowSelectDate}
|
{#if isShowSelectDate}
|
||||||
<SelectDate
|
<SelectDate
|
||||||
initialDate={DateTime.now()}
|
initialDate={DateTime.now()}
|
||||||
|
@ -191,6 +191,7 @@
|
|||||||
clickable={true}
|
clickable={true}
|
||||||
onClickPoint={(selected) => (point = selected)}
|
onClickPoint={(selected) => (point = selected)}
|
||||||
showSettings={false}
|
showSettings={false}
|
||||||
|
rounded
|
||||||
/>
|
/>
|
||||||
{/await}
|
{/await}
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,251 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
|
|
||||||
import QrCodeModal from '$lib/components/shared-components/qr-code-modal.svelte';
|
|
||||||
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
|
||||||
import { SettingInputFieldType } from '$lib/constants';
|
|
||||||
import { locale } from '$lib/stores/preferences.store';
|
|
||||||
import { serverConfig } from '$lib/stores/server-config.store';
|
|
||||||
import { makeSharedLinkUrl } from '$lib/utils';
|
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
|
||||||
import { SharedLinkType, createSharedLink, updateSharedLink, type SharedLinkResponseDto } from '@immich/sdk';
|
|
||||||
import { Button } from '@immich/ui';
|
|
||||||
import { mdiLink } from '@mdi/js';
|
|
||||||
import { DateTime, Duration } from 'luxon';
|
|
||||||
import { t } from 'svelte-i18n';
|
|
||||||
import { NotificationType, notificationController } from '../notification/notification';
|
|
||||||
import SettingInputField from '../settings/setting-input-field.svelte';
|
|
||||||
import SettingSwitch from '../settings/setting-switch.svelte';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
onClose: () => void;
|
|
||||||
albumId?: string | undefined;
|
|
||||||
assetIds?: string[];
|
|
||||||
editingLink?: SharedLinkResponseDto | undefined;
|
|
||||||
onCreated?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
onClose,
|
|
||||||
albumId = $bindable(undefined),
|
|
||||||
assetIds = $bindable([]),
|
|
||||||
editingLink = undefined,
|
|
||||||
onCreated = () => {},
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
let sharedLink: string | null = $state(null);
|
|
||||||
let description = $state('');
|
|
||||||
let allowDownload = $state(true);
|
|
||||||
let allowUpload = $state(false);
|
|
||||||
let showMetadata = $state(true);
|
|
||||||
let expirationOption: number = $state(0);
|
|
||||||
let password = $state('');
|
|
||||||
let shouldChangeExpirationTime = $state(false);
|
|
||||||
let enablePassword = $state(false);
|
|
||||||
|
|
||||||
const expirationOptions: [number, Intl.RelativeTimeFormatUnit][] = [
|
|
||||||
[30, 'minutes'],
|
|
||||||
[1, 'hour'],
|
|
||||||
[6, 'hours'],
|
|
||||||
[1, 'day'],
|
|
||||||
[7, 'days'],
|
|
||||||
[30, 'days'],
|
|
||||||
[3, 'months'],
|
|
||||||
[1, 'year'],
|
|
||||||
];
|
|
||||||
|
|
||||||
let relativeTime = $derived(new Intl.RelativeTimeFormat($locale));
|
|
||||||
let expiredDateOptions = $derived([
|
|
||||||
{ text: $t('never'), value: 0 },
|
|
||||||
...expirationOptions.map(([value, unit]) => ({
|
|
||||||
text: relativeTime.format(value, unit),
|
|
||||||
value: Duration.fromObject({ [unit]: value }).toMillis(),
|
|
||||||
})),
|
|
||||||
]);
|
|
||||||
|
|
||||||
let shareType = $derived(albumId ? SharedLinkType.Album : SharedLinkType.Individual);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!showMetadata) {
|
|
||||||
allowDownload = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (editingLink) {
|
|
||||||
if (editingLink.description) {
|
|
||||||
description = editingLink.description;
|
|
||||||
}
|
|
||||||
if (editingLink.password) {
|
|
||||||
password = editingLink.password;
|
|
||||||
}
|
|
||||||
allowUpload = editingLink.allowUpload;
|
|
||||||
allowDownload = editingLink.allowDownload;
|
|
||||||
showMetadata = editingLink.showMetadata;
|
|
||||||
|
|
||||||
albumId = editingLink.album?.id;
|
|
||||||
assetIds = editingLink.assets.map(({ id }) => id);
|
|
||||||
|
|
||||||
enablePassword = !!editingLink.password;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCreateSharedLink = async () => {
|
|
||||||
const expirationDate = expirationOption > 0 ? DateTime.now().plus(expirationOption).toISO() : undefined;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await createSharedLink({
|
|
||||||
sharedLinkCreateDto: {
|
|
||||||
type: shareType,
|
|
||||||
albumId,
|
|
||||||
assetIds,
|
|
||||||
expiresAt: expirationDate,
|
|
||||||
allowUpload,
|
|
||||||
description,
|
|
||||||
password,
|
|
||||||
allowDownload,
|
|
||||||
showMetadata,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
sharedLink = makeSharedLinkUrl($serverConfig.externalDomain, data.key);
|
|
||||||
onCreated();
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error, $t('errors.failed_to_create_shared_link'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEditLink = async () => {
|
|
||||||
if (!editingLink) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const expirationDate = expirationOption > 0 ? DateTime.now().plus(expirationOption).toISO() : null;
|
|
||||||
|
|
||||||
await updateSharedLink({
|
|
||||||
id: editingLink.id,
|
|
||||||
sharedLinkEditDto: {
|
|
||||||
description,
|
|
||||||
password: enablePassword ? password : '',
|
|
||||||
expiresAt: shouldChangeExpirationTime ? expirationDate : undefined,
|
|
||||||
allowUpload,
|
|
||||||
allowDownload,
|
|
||||||
showMetadata,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
notificationController.show({
|
|
||||||
type: NotificationType.Info,
|
|
||||||
message: $t('edited'),
|
|
||||||
});
|
|
||||||
|
|
||||||
onClose();
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error, $t('errors.failed_to_edit_shared_link'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTitle = () => {
|
|
||||||
if (sharedLink) {
|
|
||||||
return $t('view_link');
|
|
||||||
}
|
|
||||||
if (editingLink) {
|
|
||||||
return $t('edit_link');
|
|
||||||
}
|
|
||||||
return $t('create_link_to_share');
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if !sharedLink || editingLink}
|
|
||||||
<FullScreenModal title={getTitle()} icon={mdiLink} {onClose}>
|
|
||||||
<section>
|
|
||||||
{#if shareType === SharedLinkType.Album}
|
|
||||||
{#if !editingLink}
|
|
||||||
<div>{$t('album_with_link_access')}</div>
|
|
||||||
{:else}
|
|
||||||
<div class="text-sm">
|
|
||||||
{$t('public_album')} |
|
|
||||||
<span class="text-immich-primary dark:text-immich-dark-primary">{editingLink.album?.albumName}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if shareType === SharedLinkType.Individual}
|
|
||||||
{#if !editingLink}
|
|
||||||
<div>{$t('create_link_to_share_description')}</div>
|
|
||||||
{:else}
|
|
||||||
<div class="text-sm">
|
|
||||||
{$t('individual_share')} |
|
|
||||||
<span class="text-immich-primary dark:text-immich-dark-primary">{editingLink.description || ''}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="mb-2 mt-4">
|
|
||||||
<p class="text-xs">{$t('link_options').toUpperCase()}</p>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-lg bg-gray-100 p-4 dark:bg-black/40 overflow-y-auto">
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<div class="mb-2">
|
|
||||||
<SettingInputField
|
|
||||||
inputType={SettingInputFieldType.TEXT}
|
|
||||||
label={$t('description')}
|
|
||||||
bind:value={description}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-2">
|
|
||||||
<SettingInputField
|
|
||||||
inputType={SettingInputFieldType.TEXT}
|
|
||||||
label={$t('password')}
|
|
||||||
bind:value={password}
|
|
||||||
disabled={!enablePassword}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="my-3">
|
|
||||||
<SettingSwitch bind:checked={enablePassword} title={$t('require_password')} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="my-3">
|
|
||||||
<SettingSwitch bind:checked={showMetadata} title={$t('show_metadata')} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="my-3">
|
|
||||||
<SettingSwitch
|
|
||||||
bind:checked={allowDownload}
|
|
||||||
title={$t('allow_public_user_to_download')}
|
|
||||||
disabled={!showMetadata}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="my-3">
|
|
||||||
<SettingSwitch bind:checked={allowUpload} title={$t('allow_public_user_to_upload')} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if editingLink}
|
|
||||||
<div class="my-3">
|
|
||||||
<SettingSwitch bind:checked={shouldChangeExpirationTime} title={$t('change_expiration_time')} />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div class="mt-3">
|
|
||||||
<SettingSelect
|
|
||||||
bind:value={expirationOption}
|
|
||||||
options={expiredDateOptions}
|
|
||||||
label={$t('expire_after')}
|
|
||||||
disabled={editingLink && !shouldChangeExpirationTime}
|
|
||||||
number={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{#snippet stickyBottom()}
|
|
||||||
{#if editingLink}
|
|
||||||
<Button fullWidth onclick={handleEditLink}>{$t('confirm')}</Button>
|
|
||||||
{:else}
|
|
||||||
<Button fullWidth onclick={handleCreateSharedLink}>{$t('create_link')}</Button>
|
|
||||||
{/if}
|
|
||||||
{/snippet}
|
|
||||||
</FullScreenModal>
|
|
||||||
{:else}
|
|
||||||
<QrCodeModal title={$t('view_link')} {onClose} value={sharedLink} />
|
|
||||||
{/if}
|
|
@ -44,7 +44,7 @@
|
|||||||
</ModalBody>
|
</ModalBody>
|
||||||
|
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<div class="flex gap-3 w-full">
|
<div class="flex gap-3 w-full my-3">
|
||||||
{#if !hideCancelButton}
|
{#if !hideCancelButton}
|
||||||
<Button shape="round" color={cancelColor} fullWidth onclick={() => onClose(false)}>
|
<Button shape="round" color={cancelColor} fullWidth onclick={() => onClose(false)}>
|
||||||
{cancelText}
|
{cancelText}
|
||||||
|
@ -4,6 +4,8 @@
|
|||||||
import type { Action } from '$lib/components/asset-viewer/actions/action';
|
import type { Action } from '$lib/components/asset-viewer/actions/action';
|
||||||
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
|
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
|
||||||
import { AppRoute, AssetAction } from '$lib/constants';
|
import { AppRoute, AssetAction } from '$lib/constants';
|
||||||
|
import { modalManager } from '$lib/managers/modal-manager.svelte';
|
||||||
|
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
|
||||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import type { Viewport } from '$lib/stores/assets-store.svelte';
|
import type { Viewport } from '$lib/stores/assets-store.svelte';
|
||||||
@ -22,7 +24,6 @@
|
|||||||
import AssetViewer from '../../asset-viewer/asset-viewer.svelte';
|
import AssetViewer from '../../asset-viewer/asset-viewer.svelte';
|
||||||
import DeleteAssetDialog from '../../photos-page/delete-asset-dialog.svelte';
|
import DeleteAssetDialog from '../../photos-page/delete-asset-dialog.svelte';
|
||||||
import Portal from '../portal/portal.svelte';
|
import Portal from '../portal/portal.svelte';
|
||||||
import ShowShortcuts from '../show-shortcuts.svelte';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
assets: AssetResponseDto[];
|
assets: AssetResponseDto[];
|
||||||
@ -106,7 +107,6 @@
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
let showShortcuts = $state(false);
|
|
||||||
let currentViewAssetIndex = 0;
|
let currentViewAssetIndex = 0;
|
||||||
let shiftKeyIsDown = $state(false);
|
let shiftKeyIsDown = $state(false);
|
||||||
let lastAssetMouseEvent: AssetResponseDto | null = $state(null);
|
let lastAssetMouseEvent: AssetResponseDto | null = $state(null);
|
||||||
@ -263,6 +263,18 @@
|
|||||||
const focusNextAsset = () => moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, true);
|
const focusNextAsset = () => moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, true);
|
||||||
const focusPreviousAsset = () => moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, false);
|
const focusPreviousAsset = () => moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, false);
|
||||||
|
|
||||||
|
let isShortcutModalOpen = false;
|
||||||
|
|
||||||
|
const handleOpenShortcutModal = async () => {
|
||||||
|
if (isShortcutModalOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isShortcutModalOpen = true;
|
||||||
|
await modalManager.show(ShortcutsModal, {});
|
||||||
|
isShortcutModalOpen = false;
|
||||||
|
};
|
||||||
|
|
||||||
let shortcutList = $derived(
|
let shortcutList = $derived(
|
||||||
(() => {
|
(() => {
|
||||||
if ($isViewerOpen) {
|
if ($isViewerOpen) {
|
||||||
@ -270,7 +282,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const shortcuts: ShortcutOptions[] = [
|
const shortcuts: ShortcutOptions[] = [
|
||||||
{ shortcut: { key: '?', shift: true }, onShortcut: () => (showShortcuts = !showShortcuts) },
|
{ shortcut: { key: '?', shift: true }, onShortcut: handleOpenShortcutModal },
|
||||||
{ shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) },
|
{ shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) },
|
||||||
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets() },
|
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets() },
|
||||||
{ shortcut: { key: 'ArrowRight' }, preventDefault: false, onShortcut: focusNextAsset },
|
{ shortcut: { key: 'ArrowRight' }, preventDefault: false, onShortcut: focusNextAsset },
|
||||||
@ -439,10 +451,6 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showShortcuts}
|
|
||||||
<ShowShortcuts onClose={() => (showShortcuts = !showShortcuts)} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if assets.length > 0}
|
{#if assets.length > 0}
|
||||||
<div
|
<div
|
||||||
style:position="relative"
|
style:position="relative"
|
||||||
|
@ -53,6 +53,7 @@
|
|||||||
onSelect?: (assetIds: string[]) => void;
|
onSelect?: (assetIds: string[]) => void;
|
||||||
onClickPoint?: ({ lat, lng }: { lat: number; lng: number }) => void;
|
onClickPoint?: ({ lat, lng }: { lat: number; lng: number }) => void;
|
||||||
popup?: import('svelte').Snippet<[{ marker: MapMarkerResponseDto }]>;
|
popup?: import('svelte').Snippet<[{ marker: MapMarkerResponseDto }]>;
|
||||||
|
rounded?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@ -68,6 +69,7 @@
|
|||||||
onSelect = () => {},
|
onSelect = () => {},
|
||||||
onClickPoint = () => {},
|
onClickPoint = () => {},
|
||||||
popup,
|
popup,
|
||||||
|
rounded = false,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let map: maplibregl.Map | undefined = $state();
|
let map: maplibregl.Map | undefined = $state();
|
||||||
@ -247,7 +249,7 @@
|
|||||||
<MapLibre
|
<MapLibre
|
||||||
{hash}
|
{hash}
|
||||||
style=""
|
style=""
|
||||||
class="h-full rounded-2xl"
|
class="h-full {rounded ? 'rounded-2xl' : 'rounded-none'}"
|
||||||
{center}
|
{center}
|
||||||
{zoom}
|
{zoom}
|
||||||
attributionControl={false}
|
attributionControl={false}
|
||||||
@ -274,7 +276,9 @@
|
|||||||
{#if showSettings}
|
{#if showSettings}
|
||||||
<Control>
|
<Control>
|
||||||
<ControlGroup>
|
<ControlGroup>
|
||||||
<ControlButton onclick={handleSettingsClick}><Icon path={mdiCog} size="100%" /></ControlButton>
|
<ControlButton onclick={handleSettingsClick}
|
||||||
|
><Icon path={mdiCog} size="100%" class="text-black/80" /></ControlButton
|
||||||
|
>
|
||||||
</ControlGroup>
|
</ControlGroup>
|
||||||
</Control>
|
</Control>
|
||||||
{/if}
|
{/if}
|
||||||
@ -283,7 +287,7 @@
|
|||||||
<Control position="top-right">
|
<Control position="top-right">
|
||||||
<ControlGroup>
|
<ControlGroup>
|
||||||
<ControlButton onclick={() => onOpenInMapView()}>
|
<ControlButton onclick={() => onOpenInMapView()}>
|
||||||
<Icon title={$t('open_in_map_view')} path={mdiMap} size="100%" />
|
<Icon title={$t('open_in_map_view')} path={mdiMap} size="100%" class="text-black/80" />
|
||||||
</ControlButton>
|
</ControlButton>
|
||||||
</ControlGroup>
|
</ControlGroup>
|
||||||
</Control>
|
</Control>
|
||||||
|
@ -49,7 +49,10 @@
|
|||||||
|
|
||||||
<svelte:window bind:innerWidth />
|
<svelte:window bind:innerWidth />
|
||||||
|
|
||||||
<nav id="dashboard-navbar" class="z-auto max-md:h-[var(--navbar-height-md)] h-[var(--navbar-height)] w-dvw text-sm">
|
<nav
|
||||||
|
id="dashboard-navbar"
|
||||||
|
class="z-auto max-md:h-[var(--navbar-height-md)] h-[var(--navbar-height)] w-dvw text-sm overflow-hidden"
|
||||||
|
>
|
||||||
<SkipLink text={$t('skip_to_content')} />
|
<SkipLink text={$t('skip_to_content')} />
|
||||||
<div
|
<div
|
||||||
class="grid h-full grid-cols-[theme(spacing.32)_auto] items-center border-b bg-immich-bg py-2 dark:border-b-immich-dark-gray dark:bg-immich-dark-bg sidebar:grid-cols-[theme(spacing.64)_auto]"
|
class="grid h-full grid-cols-[theme(spacing.32)_auto] items-center border-b bg-immich-bg py-2 dark:border-b-immich-dark-gray dark:bg-immich-dark-bg sidebar:grid-cols-[theme(spacing.64)_auto]"
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fade } from 'svelte/transition';
|
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||||
import Icon from '$lib/components/elements/icon.svelte';
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
import {
|
import {
|
||||||
isComponentNotification,
|
isComponentNotification,
|
||||||
@ -8,10 +8,10 @@
|
|||||||
type ComponentNotification,
|
type ComponentNotification,
|
||||||
type Notification,
|
type Notification,
|
||||||
} from '$lib/components/shared-components/notification/notification';
|
} from '$lib/components/shared-components/notification/notification';
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { mdiCloseCircleOutline, mdiInformationOutline, mdiWindowClose } from '@mdi/js';
|
import { mdiCloseCircleOutline, mdiInformationOutline, mdiWindowClose } from '@mdi/js';
|
||||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
import { onMount } from 'svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
notification: Notification | ComponentNotification;
|
notification: Notification | ComponentNotification;
|
||||||
@ -100,7 +100,7 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="whitespace-pre-wrap ps-[28px] pe-[16px] text-sm" data-testid="message">
|
<p class="whitespace-pre-wrap ps-[28px] pe-[16px] text-sm text-black/80" data-testid="message">
|
||||||
{#if isComponentNotification(notification)}
|
{#if isComponentNotification(notification)}
|
||||||
<notification.component.type {...notification.component.props} />
|
<notification.component.type {...notification.component.props} />
|
||||||
{:else}
|
{:else}
|
||||||
|
@ -1,19 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { AppRoute } from '$lib/constants';
|
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { searchStore } from '$lib/stores/search.svelte';
|
|
||||||
import { mdiClose, mdiMagnify, mdiTune } from '@mdi/js';
|
|
||||||
import SearchHistoryBox from './search-history-box.svelte';
|
|
||||||
import SearchFilterModal from './search-filter-modal.svelte';
|
|
||||||
import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk';
|
|
||||||
import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
|
|
||||||
import { handlePromiseError } from '$lib/utils';
|
|
||||||
import { shortcuts } from '$lib/actions/shortcut';
|
|
||||||
import { focusOutside } from '$lib/actions/focus-outside';
|
import { focusOutside } from '$lib/actions/focus-outside';
|
||||||
|
import { shortcuts } from '$lib/actions/shortcut';
|
||||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { AppRoute } from '$lib/constants';
|
||||||
|
import { modalManager } from '$lib/managers/modal-manager.svelte';
|
||||||
|
import SearchFilterModal from '$lib/modals/SearchFilterModal.svelte';
|
||||||
|
import { searchStore } from '$lib/stores/search.svelte';
|
||||||
|
import { handlePromiseError } from '$lib/utils';
|
||||||
import { generateId } from '$lib/utils/generate-id';
|
import { generateId } from '$lib/utils/generate-id';
|
||||||
|
import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
|
||||||
|
import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk';
|
||||||
|
import { mdiClose, mdiMagnify, mdiTune } from '@mdi/js';
|
||||||
import { onDestroy, tick } from 'svelte';
|
import { onDestroy, tick } from 'svelte';
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
|
import SearchHistoryBox from './search-history-box.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
value?: string;
|
value?: string;
|
||||||
@ -28,10 +29,10 @@
|
|||||||
let input = $state<HTMLInputElement>();
|
let input = $state<HTMLInputElement>();
|
||||||
let searchHistoryBox = $state<ReturnType<typeof SearchHistoryBox>>();
|
let searchHistoryBox = $state<ReturnType<typeof SearchHistoryBox>>();
|
||||||
let showSuggestions = $state(false);
|
let showSuggestions = $state(false);
|
||||||
let showFilter = $state(false);
|
|
||||||
let isSearchSuggestions = $state(false);
|
let isSearchSuggestions = $state(false);
|
||||||
let selectedId: string | undefined = $state();
|
let selectedId: string | undefined = $state();
|
||||||
let isFocus = $state(false);
|
let isFocus = $state(false);
|
||||||
|
let close: (() => Promise<void>) | undefined;
|
||||||
|
|
||||||
const listboxId = generateId();
|
const listboxId = generateId();
|
||||||
|
|
||||||
@ -43,7 +44,6 @@
|
|||||||
const params = getMetadataSearchQuery(payload);
|
const params = getMetadataSearchQuery(payload);
|
||||||
|
|
||||||
closeDropdown();
|
closeDropdown();
|
||||||
showFilter = false;
|
|
||||||
searchStore.isSearchEnabled = false;
|
searchStore.isSearchEnabled = false;
|
||||||
await goto(`${AppRoute.SEARCH}?${params}`);
|
await goto(`${AppRoute.SEARCH}?${params}`);
|
||||||
};
|
};
|
||||||
@ -83,13 +83,27 @@
|
|||||||
await handleSearch(searchPayload);
|
await handleSearch(searchPayload);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFilterClick = () => {
|
const onFilterClick = async () => {
|
||||||
showFilter = !showFilter;
|
|
||||||
value = '';
|
value = '';
|
||||||
|
|
||||||
if (showFilter) {
|
if (close) {
|
||||||
closeDropdown();
|
await close();
|
||||||
|
close = undefined;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const result = modalManager.open(SearchFilterModal, { searchQuery });
|
||||||
|
close = result.close;
|
||||||
|
closeDropdown();
|
||||||
|
|
||||||
|
const searchResult = await result.onClose;
|
||||||
|
close = undefined;
|
||||||
|
|
||||||
|
if (!searchResult) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await handleSearch(searchResult);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = () => {
|
const onSubmit = () => {
|
||||||
@ -122,7 +136,6 @@
|
|||||||
|
|
||||||
const onEscape = () => {
|
const onEscape = () => {
|
||||||
closeDropdown();
|
closeDropdown();
|
||||||
showFilter = false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onArrow = async (direction: 1 | -1) => {
|
const onArrow = async (direction: 1 | -1) => {
|
||||||
@ -221,9 +234,7 @@
|
|||||||
class="w-full transition-all border-2 px-14 py-4 max-md:py-2 text-immich-fg/75 dark:text-immich-dark-fg
|
class="w-full transition-all border-2 px-14 py-4 max-md:py-2 text-immich-fg/75 dark:text-immich-dark-fg
|
||||||
{grayTheme ? 'dark:bg-immich-dark-gray' : 'dark:bg-immich-dark-bg'}
|
{grayTheme ? 'dark:bg-immich-dark-gray' : 'dark:bg-immich-dark-bg'}
|
||||||
{showSuggestions && isSearchSuggestions ? 'rounded-t-3xl' : 'rounded-3xl bg-gray-200'}
|
{showSuggestions && isSearchSuggestions ? 'rounded-t-3xl' : 'rounded-3xl bg-gray-200'}
|
||||||
{searchStore.isSearchEnabled && !showFilter
|
{searchStore.isSearchEnabled ? 'border-gray-200 dark:border-gray-700 bg-white' : 'border-transparent'}"
|
||||||
? 'border-gray-200 dark:border-gray-700 bg-white'
|
|
||||||
: 'border-transparent'}"
|
|
||||||
placeholder={$t('search_your_photos')}
|
placeholder={$t('search_your_photos')}
|
||||||
required
|
required
|
||||||
pattern="^(?!m:$).*$"
|
pattern="^(?!m:$).*$"
|
||||||
@ -231,7 +242,6 @@
|
|||||||
bind:this={input}
|
bind:this={input}
|
||||||
onfocus={openDropdown}
|
onfocus={openDropdown}
|
||||||
oninput={onInput}
|
oninput={onInput}
|
||||||
disabled={showFilter}
|
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-controls={listboxId}
|
aria-controls={listboxId}
|
||||||
aria-activedescendant={selectedId ?? ''}
|
aria-activedescendant={selectedId ?? ''}
|
||||||
@ -285,22 +295,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="absolute inset-y-0 start-0 flex items-center ps-2">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-2">
|
||||||
<CircleIconButton
|
<CircleIconButton type="submit" title={$t('search')} icon={mdiMagnify} size="20" onclick={() => {}} />
|
||||||
type="submit"
|
|
||||||
disabled={showFilter}
|
|
||||||
title={$t('search')}
|
|
||||||
icon={mdiMagnify}
|
|
||||||
size="20"
|
|
||||||
onclick={() => {}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{#if showFilter}
|
|
||||||
<SearchFilterModal
|
|
||||||
{searchQuery}
|
|
||||||
onSearch={(payload) => handleSearch(payload)}
|
|
||||||
onClose={() => (showFilter = false)}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||||
import { serverConfig } from '$lib/stores/server-config.store';
|
|
||||||
import { copyToClipboard, makeSharedLinkUrl } from '$lib/utils';
|
import { copyToClipboard, makeSharedLinkUrl } from '$lib/utils';
|
||||||
import type { SharedLinkResponseDto } from '@immich/sdk';
|
import type { SharedLinkResponseDto } from '@immich/sdk';
|
||||||
import { mdiContentCopy } from '@mdi/js';
|
import { mdiContentCopy } from '@mdi/js';
|
||||||
@ -15,7 +14,7 @@
|
|||||||
let { link, menuItem = false }: Props = $props();
|
let { link, menuItem = false }: Props = $props();
|
||||||
|
|
||||||
const handleCopy = async () => {
|
const handleCopy = async () => {
|
||||||
await copyToClipboard(makeSharedLinkUrl($serverConfig.externalDomain, link.key));
|
await copyToClipboard(makeSharedLinkUrl(link.key));
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
114
web/src/lib/components/user-settings-page/PinCodeInput.svelte
Normal file
114
web/src/lib/components/user-settings-page/PinCodeInput.svelte
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
label: string;
|
||||||
|
value?: string;
|
||||||
|
pinLength?: number;
|
||||||
|
tabindexStart?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { label, value = $bindable(''), pinLength = 6, tabindexStart = 0 }: Props = $props();
|
||||||
|
|
||||||
|
let pinValues = $state(Array.from({ length: pinLength }).fill(''));
|
||||||
|
let pinCodeInputElements: HTMLInputElement[] = $state([]);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (value === '') {
|
||||||
|
pinValues = Array.from({ length: pinLength }).fill('');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const focusNext = (index: number) => {
|
||||||
|
pinCodeInputElements[Math.min(index + 1, pinLength - 1)]?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const focusPrev = (index: number) => {
|
||||||
|
if (index > 0) {
|
||||||
|
pinCodeInputElements[index - 1]?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInput = (event: Event, index: number) => {
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
let currentPinValue = target.value;
|
||||||
|
|
||||||
|
if (target.value.length > 1) {
|
||||||
|
currentPinValue = value.slice(0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number.isNaN(Number(value))) {
|
||||||
|
pinValues[index] = '';
|
||||||
|
target.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pinValues[index] = currentPinValue;
|
||||||
|
|
||||||
|
value = pinValues.join('').trim();
|
||||||
|
|
||||||
|
if (value && index < pinLength - 1) {
|
||||||
|
focusNext(index);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent & { currentTarget: EventTarget & HTMLInputElement }) {
|
||||||
|
const target = event.currentTarget as HTMLInputElement;
|
||||||
|
const index = pinCodeInputElements.indexOf(target);
|
||||||
|
|
||||||
|
switch (event.key) {
|
||||||
|
case 'Tab': {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'Backspace': {
|
||||||
|
if (target.value === '' && index > 0) {
|
||||||
|
focusPrev(index);
|
||||||
|
pinValues[index - 1] = '';
|
||||||
|
} else if (target.value !== '') {
|
||||||
|
pinValues[index] = '';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'ArrowLeft': {
|
||||||
|
if (index > 0) {
|
||||||
|
focusPrev(index);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'ArrowRight': {
|
||||||
|
if (index < pinLength - 1) {
|
||||||
|
focusNext(index);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
if (Number.isNaN(Number(event.key))) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
{#if label}
|
||||||
|
<label class="text-xs text-dark" for={pinCodeInputElements[0]?.id}>{label.toUpperCase()}</label>
|
||||||
|
{/if}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{#each { length: pinLength } as _, index (index)}
|
||||||
|
<input
|
||||||
|
tabindex={tabindexStart + index}
|
||||||
|
type="text"
|
||||||
|
inputmode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
maxlength="1"
|
||||||
|
bind:this={pinCodeInputElements[index]}
|
||||||
|
id="pin-code-{index}"
|
||||||
|
class="h-12 w-10 rounded-xl border-2 border-suble dark:border-gray-700 bg-transparent text-center text-lg font-medium focus:border-immich-primary focus:ring-primary dark:focus:border-primary font-mono"
|
||||||
|
bind:value={pinValues[index]}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
|
oninput={(event) => handleInput(event, index)}
|
||||||
|
aria-label={`PIN digit ${index + 1} of ${pinLength}${label ? ` for ${label}` : ''}`}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
116
web/src/lib/components/user-settings-page/PinCodeSettings.svelte
Normal file
116
web/src/lib/components/user-settings-page/PinCodeSettings.svelte
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
notificationController,
|
||||||
|
NotificationType,
|
||||||
|
} from '$lib/components/shared-components/notification/notification';
|
||||||
|
import PinCodeInput from '$lib/components/user-settings-page/PinCodeInput.svelte';
|
||||||
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
|
import { changePinCode, getAuthStatus, setupPinCode } from '@immich/sdk';
|
||||||
|
import { Button } from '@immich/ui';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
|
let hasPinCode = $state(false);
|
||||||
|
let currentPinCode = $state('');
|
||||||
|
let newPinCode = $state('');
|
||||||
|
let confirmPinCode = $state('');
|
||||||
|
let isLoading = $state(false);
|
||||||
|
let canSubmit = $derived(
|
||||||
|
(hasPinCode ? currentPinCode.length === 6 : true) && confirmPinCode.length === 6 && newPinCode === confirmPinCode,
|
||||||
|
);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const authStatus = await getAuthStatus();
|
||||||
|
hasPinCode = authStatus.pinCode;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = async (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
await (hasPinCode ? handleChange() : handleSetup());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSetup = async () => {
|
||||||
|
isLoading = true;
|
||||||
|
try {
|
||||||
|
await setupPinCode({ pinCodeSetupDto: { pinCode: newPinCode } });
|
||||||
|
|
||||||
|
resetForm();
|
||||||
|
|
||||||
|
notificationController.show({
|
||||||
|
message: $t('pin_code_setup_successfully'),
|
||||||
|
type: NotificationType.Info,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, $t('unable_to_setup_pin_code'));
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
hasPinCode = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = async () => {
|
||||||
|
isLoading = true;
|
||||||
|
try {
|
||||||
|
await changePinCode({ pinCodeChangeDto: { pinCode: currentPinCode, newPinCode } });
|
||||||
|
|
||||||
|
resetForm();
|
||||||
|
|
||||||
|
notificationController.show({
|
||||||
|
message: $t('pin_code_changed_successfully'),
|
||||||
|
type: NotificationType.Info,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, $t('unable_to_change_pin_code'));
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
currentPinCode = '';
|
||||||
|
newPinCode = '';
|
||||||
|
confirmPinCode = '';
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="my-4">
|
||||||
|
<div in:fade={{ duration: 200 }}>
|
||||||
|
<form autocomplete="off" onsubmit={handleSubmit} class="mt-6">
|
||||||
|
<div class="flex flex-col gap-6 place-items-center place-content-center">
|
||||||
|
{#if hasPinCode}
|
||||||
|
<p class="text-dark">Change PIN code</p>
|
||||||
|
<PinCodeInput label={$t('current_pin_code')} bind:value={currentPinCode} tabindexStart={1} pinLength={6} />
|
||||||
|
|
||||||
|
<PinCodeInput label={$t('new_pin_code')} bind:value={newPinCode} tabindexStart={7} pinLength={6} />
|
||||||
|
|
||||||
|
<PinCodeInput
|
||||||
|
label={$t('confirm_new_pin_code')}
|
||||||
|
bind:value={confirmPinCode}
|
||||||
|
tabindexStart={13}
|
||||||
|
pinLength={6}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<p class="text-dark">{$t('setup_pin_code')}</p>
|
||||||
|
<PinCodeInput label={$t('new_pin_code')} bind:value={newPinCode} tabindexStart={1} pinLength={6} />
|
||||||
|
|
||||||
|
<PinCodeInput
|
||||||
|
label={$t('confirm_new_pin_code')}
|
||||||
|
bind:value={confirmPinCode}
|
||||||
|
tabindexStart={7}
|
||||||
|
pinLength={6}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2 mt-4">
|
||||||
|
<Button shape="round" color="secondary" type="button" size="small" onclick={resetForm}>
|
||||||
|
{$t('clear')}
|
||||||
|
</Button>
|
||||||
|
<Button shape="round" type="submit" size="small" loading={isLoading} disabled={!canSubmit}>
|
||||||
|
{hasPinCode ? $t('save') : $t('create')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
@ -1,24 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
import ChangePinCodeSettings from '$lib/components/user-settings-page/PinCodeSettings.svelte';
|
||||||
|
import DownloadSettings from '$lib/components/user-settings-page/download-settings.svelte';
|
||||||
|
import FeatureSettings from '$lib/components/user-settings-page/feature-settings.svelte';
|
||||||
|
import NotificationsSettings from '$lib/components/user-settings-page/notifications-settings.svelte';
|
||||||
|
import UserPurchaseSettings from '$lib/components/user-settings-page/user-purchase-settings.svelte';
|
||||||
|
import UserUsageStatistic from '$lib/components/user-settings-page/user-usage-statistic.svelte';
|
||||||
import { OpenSettingQueryParameterValue, QueryParameter } from '$lib/constants';
|
import { OpenSettingQueryParameterValue, QueryParameter } from '$lib/constants';
|
||||||
import { featureFlags } from '$lib/stores/server-config.store';
|
import { featureFlags } from '$lib/stores/server-config.store';
|
||||||
import { user } from '$lib/stores/user.store';
|
import { user } from '$lib/stores/user.store';
|
||||||
import { oauth } from '$lib/utils';
|
import { oauth } from '$lib/utils';
|
||||||
import { type ApiKeyResponseDto, type SessionResponseDto } from '@immich/sdk';
|
import { type ApiKeyResponseDto, type SessionResponseDto } from '@immich/sdk';
|
||||||
import SettingAccordionState from '../shared-components/settings/setting-accordion-state.svelte';
|
|
||||||
import SettingAccordion from '../shared-components/settings/setting-accordion.svelte';
|
|
||||||
import AppSettings from './app-settings.svelte';
|
|
||||||
import ChangePasswordSettings from './change-password-settings.svelte';
|
|
||||||
import DeviceList from './device-list.svelte';
|
|
||||||
import OAuthSettings from './oauth-settings.svelte';
|
|
||||||
import PartnerSettings from './partner-settings.svelte';
|
|
||||||
import UserAPIKeyList from './user-api-key-list.svelte';
|
|
||||||
import UserProfileSettings from './user-profile-settings.svelte';
|
|
||||||
import NotificationsSettings from '$lib/components/user-settings-page/notifications-settings.svelte';
|
|
||||||
import { t } from 'svelte-i18n';
|
|
||||||
import DownloadSettings from '$lib/components/user-settings-page/download-settings.svelte';
|
|
||||||
import UserPurchaseSettings from '$lib/components/user-settings-page/user-purchase-settings.svelte';
|
|
||||||
import FeatureSettings from '$lib/components/user-settings-page/feature-settings.svelte';
|
|
||||||
import {
|
import {
|
||||||
mdiAccountGroupOutline,
|
mdiAccountGroupOutline,
|
||||||
mdiAccountOutline,
|
mdiAccountOutline,
|
||||||
@ -29,11 +21,21 @@
|
|||||||
mdiDownload,
|
mdiDownload,
|
||||||
mdiFeatureSearchOutline,
|
mdiFeatureSearchOutline,
|
||||||
mdiKeyOutline,
|
mdiKeyOutline,
|
||||||
|
mdiLockSmart,
|
||||||
mdiOnepassword,
|
mdiOnepassword,
|
||||||
mdiServerOutline,
|
mdiServerOutline,
|
||||||
mdiTwoFactorAuthentication,
|
mdiTwoFactorAuthentication,
|
||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
import UserUsageStatistic from '$lib/components/user-settings-page/user-usage-statistic.svelte';
|
import { t } from 'svelte-i18n';
|
||||||
|
import SettingAccordionState from '../shared-components/settings/setting-accordion-state.svelte';
|
||||||
|
import SettingAccordion from '../shared-components/settings/setting-accordion.svelte';
|
||||||
|
import AppSettings from './app-settings.svelte';
|
||||||
|
import ChangePasswordSettings from './change-password-settings.svelte';
|
||||||
|
import DeviceList from './device-list.svelte';
|
||||||
|
import OAuthSettings from './oauth-settings.svelte';
|
||||||
|
import PartnerSettings from './partner-settings.svelte';
|
||||||
|
import UserAPIKeyList from './user-api-key-list.svelte';
|
||||||
|
import UserProfileSettings from './user-profile-settings.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
keys?: ApiKeyResponseDto[];
|
keys?: ApiKeyResponseDto[];
|
||||||
@ -135,6 +137,16 @@
|
|||||||
<PartnerSettings user={$user} />
|
<PartnerSettings user={$user} />
|
||||||
</SettingAccordion>
|
</SettingAccordion>
|
||||||
|
|
||||||
|
<SettingAccordion
|
||||||
|
icon={mdiLockSmart}
|
||||||
|
key="user-pin-code-settings"
|
||||||
|
title={$t('user_pin_code_settings')}
|
||||||
|
subtitle={$t('user_pin_code_settings_description')}
|
||||||
|
autoScrollTo={true}
|
||||||
|
>
|
||||||
|
<ChangePinCodeSettings />
|
||||||
|
</SettingAccordion>
|
||||||
|
|
||||||
<SettingAccordion
|
<SettingAccordion
|
||||||
icon={mdiKeyOutline}
|
icon={mdiKeyOutline}
|
||||||
key="user-purchase-settings"
|
key="user-purchase-settings"
|
||||||
|
@ -367,8 +367,6 @@ export enum SettingInputFieldType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const AlbumPageViewMode = {
|
export const AlbumPageViewMode = {
|
||||||
LINK_SHARING: 'link-sharing',
|
|
||||||
SELECT_USERS: 'select-users',
|
|
||||||
SELECT_THUMBNAIL: 'select-thumbnail',
|
SELECT_THUMBNAIL: 'select-thumbnail',
|
||||||
SELECT_ASSETS: 'select-assets',
|
SELECT_ASSETS: 'select-assets',
|
||||||
VIEW_USERS: 'view-users',
|
VIEW_USERS: 'view-users',
|
||||||
@ -377,8 +375,6 @@ export const AlbumPageViewMode = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type AlbumPageViewMode =
|
export type AlbumPageViewMode =
|
||||||
| typeof AlbumPageViewMode.LINK_SHARING
|
|
||||||
| typeof AlbumPageViewMode.SELECT_USERS
|
|
||||||
| typeof AlbumPageViewMode.SELECT_THUMBNAIL
|
| typeof AlbumPageViewMode.SELECT_THUMBNAIL
|
||||||
| typeof AlbumPageViewMode.SELECT_ASSETS
|
| typeof AlbumPageViewMode.SELECT_ASSETS
|
||||||
| typeof AlbumPageViewMode.VIEW_USERS
|
| typeof AlbumPageViewMode.VIEW_USERS
|
||||||
|
@ -3,9 +3,8 @@
|
|||||||
import Dropdown from '$lib/components/elements/dropdown.svelte';
|
import Dropdown from '$lib/components/elements/dropdown.svelte';
|
||||||
import Icon from '$lib/components/elements/icon.svelte';
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
|
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
|
||||||
import QrCodeModal from '$lib/components/shared-components/qr-code-modal.svelte';
|
|
||||||
import { AppRoute } from '$lib/constants';
|
import { AppRoute } from '$lib/constants';
|
||||||
import { serverConfig } from '$lib/stores/server-config.store';
|
import QrCodeModal from '$lib/modals/QrCodeModal.svelte';
|
||||||
import { makeSharedLinkUrl } from '$lib/utils';
|
import { makeSharedLinkUrl } from '$lib/utils';
|
||||||
import {
|
import {
|
||||||
AlbumUserRole,
|
AlbumUserRole,
|
||||||
@ -20,23 +19,21 @@
|
|||||||
import { mdiCheck, mdiEye, mdiLink, mdiPencil } from '@mdi/js';
|
import { mdiCheck, mdiEye, mdiLink, mdiPencil } from '@mdi/js';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import UserAvatar from '../shared-components/user-avatar.svelte';
|
import UserAvatar from '../components/shared-components/user-avatar.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
album: AlbumResponseDto;
|
album: AlbumResponseDto;
|
||||||
onClose: () => void;
|
onClose: (result?: { action: 'sharedLink' } | { action: 'sharedUsers'; data: AlbumUserAddDto[] }) => void;
|
||||||
onSelect: (selectedUsers: AlbumUserAddDto[]) => void;
|
|
||||||
onShare: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let { album, onClose, onSelect, onShare }: Props = $props();
|
let { album, onClose }: Props = $props();
|
||||||
|
|
||||||
let users: UserResponseDto[] = $state([]);
|
let users: UserResponseDto[] = $state([]);
|
||||||
let selectedUsers: Record<string, { user: UserResponseDto; role: AlbumUserRole }> = $state({});
|
let selectedUsers: Record<string, { user: UserResponseDto; role: AlbumUserRole }> = $state({});
|
||||||
|
|
||||||
let sharedLinkUrl = $state('');
|
let sharedLinkUrl = $state('');
|
||||||
const handleViewQrCode = (sharedLink: SharedLinkResponseDto) => {
|
const handleViewQrCode = (sharedLink: SharedLinkResponseDto) => {
|
||||||
sharedLinkUrl = makeSharedLinkUrl($serverConfig.externalDomain, sharedLink.key);
|
sharedLinkUrl = makeSharedLinkUrl(sharedLink.key);
|
||||||
};
|
};
|
||||||
|
|
||||||
const roleOptions: Array<{ title: string; value: AlbumUserRole | 'none'; icon?: string }> = [
|
const roleOptions: Array<{ title: string; value: AlbumUserRole | 'none'; icon?: string }> = [
|
||||||
@ -160,8 +157,10 @@
|
|||||||
shape="round"
|
shape="round"
|
||||||
disabled={Object.keys(selectedUsers).length === 0}
|
disabled={Object.keys(selectedUsers).length === 0}
|
||||||
onclick={() =>
|
onclick={() =>
|
||||||
onSelect(Object.values(selectedUsers).map(({ user, ...rest }) => ({ userId: user.id, ...rest })))}
|
onClose({
|
||||||
>{$t('add')}</Button
|
action: 'sharedUsers',
|
||||||
|
data: Object.values(selectedUsers).map(({ user, ...rest }) => ({ userId: user.id, ...rest })),
|
||||||
|
})}>{$t('add')}</Button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@ -182,7 +181,13 @@
|
|||||||
</Stack>
|
</Stack>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<Button leadingIcon={mdiLink} size="small" shape="round" fullWidth onclick={onShare}>{$t('create_link')}</Button>
|
<Button
|
||||||
|
leadingIcon={mdiLink}
|
||||||
|
size="small"
|
||||||
|
shape="round"
|
||||||
|
fullWidth
|
||||||
|
onclick={() => onClose({ action: 'sharedLink' })}>{$t('create_link')}</Button
|
||||||
|
>
|
||||||
</Stack>
|
</Stack>
|
||||||
</FullScreenModal>
|
</FullScreenModal>
|
||||||
{/if}
|
{/if}
|
@ -1,24 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
|
|
||||||
import QRCode from '$lib/components/shared-components/qrcode.svelte';
|
import QRCode from '$lib/components/shared-components/qrcode.svelte';
|
||||||
import { copyToClipboard } from '$lib/utils';
|
import { copyToClipboard } from '$lib/utils';
|
||||||
import { HStack, IconButton, Input } from '@immich/ui';
|
import { HStack, IconButton, Input, Modal, ModalBody } from '@immich/ui';
|
||||||
import { mdiContentCopy, mdiLink } from '@mdi/js';
|
import { mdiContentCopy, mdiLink } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
title: string;
|
title: string;
|
||||||
onClose: () => void;
|
|
||||||
value: string;
|
value: string;
|
||||||
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { onClose, title, value }: Props = $props();
|
let { title, value, onClose }: Props = $props();
|
||||||
|
|
||||||
let modalWidth = $state(0);
|
let modalWidth = $state(0);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<FullScreenModal {title} icon={mdiLink} {onClose}>
|
<Modal {title} icon={mdiLink} {onClose} size="small">
|
||||||
<div class="w-full">
|
<ModalBody>
|
||||||
<div class="w-full py-2 px-10">
|
<div class="w-full py-2 px-10">
|
||||||
<div bind:clientWidth={modalWidth} class="w-full">
|
<div bind:clientWidth={modalWidth} class="w-full">
|
||||||
<QRCode {value} width={modalWidth} />
|
<QRCode {value} width={modalWidth} />
|
||||||
@ -37,5 +36,5 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</HStack>
|
</HStack>
|
||||||
</div>
|
</ModalBody>
|
||||||
</FullScreenModal>
|
</Modal>
|
@ -1,8 +1,8 @@
|
|||||||
<script lang="ts" module>
|
<script lang="ts" module>
|
||||||
import { MediaType, QueryType, validQueryTypes } from '$lib/constants';
|
import { MediaType, QueryType, validQueryTypes } from '$lib/constants';
|
||||||
import type { SearchDateFilter } from './search-date-section.svelte';
|
import type { SearchDateFilter } from '../components/shared-components/search-bar/search-date-section.svelte';
|
||||||
import type { SearchDisplayFilters } from './search-display-section.svelte';
|
import type { SearchDisplayFilters } from '../components/shared-components/search-bar/search-display-section.svelte';
|
||||||
import type { SearchLocationFilter } from './search-location-section.svelte';
|
import type { SearchLocationFilter } from '../components/shared-components/search-bar/search-location-section.svelte';
|
||||||
|
|
||||||
export type SearchFilter = {
|
export type SearchFilter = {
|
||||||
query: string;
|
query: string;
|
||||||
@ -19,32 +19,32 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
|
import SearchCameraSection, {
|
||||||
|
type SearchCameraFilter,
|
||||||
|
} from '$lib/components/shared-components/search-bar/search-camera-section.svelte';
|
||||||
|
import SearchDateSection from '$lib/components/shared-components/search-bar/search-date-section.svelte';
|
||||||
|
import SearchDisplaySection from '$lib/components/shared-components/search-bar/search-display-section.svelte';
|
||||||
|
import SearchLocationSection from '$lib/components/shared-components/search-bar/search-location-section.svelte';
|
||||||
|
import SearchMediaSection from '$lib/components/shared-components/search-bar/search-media-section.svelte';
|
||||||
|
import SearchPeopleSection from '$lib/components/shared-components/search-bar/search-people-section.svelte';
|
||||||
|
import SearchRatingsSection from '$lib/components/shared-components/search-bar/search-ratings-section.svelte';
|
||||||
|
import SearchTagsSection from '$lib/components/shared-components/search-bar/search-tags-section.svelte';
|
||||||
|
import SearchTextSection from '$lib/components/shared-components/search-bar/search-text-section.svelte';
|
||||||
import { preferences } from '$lib/stores/user.store';
|
import { preferences } from '$lib/stores/user.store';
|
||||||
import { parseUtcDate } from '$lib/utils/date-time';
|
import { parseUtcDate } from '$lib/utils/date-time';
|
||||||
import { generateId } from '$lib/utils/generate-id';
|
import { generateId } from '$lib/utils/generate-id';
|
||||||
import { AssetTypeEnum, AssetVisibility, type MetadataSearchDto, type SmartSearchDto } from '@immich/sdk';
|
import { AssetTypeEnum, AssetVisibility, type MetadataSearchDto, type SmartSearchDto } from '@immich/sdk';
|
||||||
import { Button } from '@immich/ui';
|
import { Button, Modal, ModalBody, ModalFooter } from '@immich/ui';
|
||||||
import { mdiTune } from '@mdi/js';
|
import { mdiTune } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import { SvelteSet } from 'svelte/reactivity';
|
import { SvelteSet } from 'svelte/reactivity';
|
||||||
import SearchCameraSection, { type SearchCameraFilter } from './search-camera-section.svelte';
|
|
||||||
import SearchDateSection from './search-date-section.svelte';
|
|
||||||
import SearchDisplaySection from './search-display-section.svelte';
|
|
||||||
import SearchLocationSection from './search-location-section.svelte';
|
|
||||||
import SearchMediaSection from './search-media-section.svelte';
|
|
||||||
import SearchPeopleSection from './search-people-section.svelte';
|
|
||||||
import SearchRatingsSection from './search-ratings-section.svelte';
|
|
||||||
import SearchTagsSection from './search-tags-section.svelte';
|
|
||||||
import SearchTextSection from './search-text-section.svelte';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
searchQuery: MetadataSearchDto | SmartSearchDto;
|
searchQuery: MetadataSearchDto | SmartSearchDto;
|
||||||
onClose: () => void;
|
onClose: (search?: SmartSearchDto | MetadataSearchDto) => void;
|
||||||
onSearch: (search: SmartSearchDto | MetadataSearchDto) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let { searchQuery, onClose, onSearch }: Props = $props();
|
let { searchQuery, onClose }: Props = $props();
|
||||||
|
|
||||||
const parseOptionalDate = (dateString?: string) => (dateString ? parseUtcDate(dateString) : undefined);
|
const parseOptionalDate = (dateString?: string) => (dateString ? parseUtcDate(dateString) : undefined);
|
||||||
const toStartOfDayDate = (dateString: string) => parseUtcDate(dateString)?.startOf('day').toISODate() || undefined;
|
const toStartOfDayDate = (dateString: string) => parseUtcDate(dateString)?.startOf('day').toISODate() || undefined;
|
||||||
@ -141,7 +141,7 @@
|
|||||||
rating: filter.rating,
|
rating: filter.rating,
|
||||||
};
|
};
|
||||||
|
|
||||||
onSearch(payload);
|
onClose(payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onreset = (event: Event) => {
|
const onreset = (event: Event) => {
|
||||||
@ -161,7 +161,8 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<FullScreenModal icon={mdiTune} width="extra-wide" title={$t('search_options')} {onClose}>
|
<Modal icon={mdiTune} size="giant" title={$t('search_options')} {onClose}>
|
||||||
|
<ModalBody>
|
||||||
<form id={formId} autocomplete="off" {onsubmit} {onreset}>
|
<form id={formId} autocomplete="off" {onsubmit} {onreset}>
|
||||||
<div class="space-y-10 pb-10" tabindex="-1">
|
<div class="space-y-10 pb-10" tabindex="-1">
|
||||||
<!-- PEOPLE -->
|
<!-- PEOPLE -->
|
||||||
@ -196,9 +197,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</ModalBody>
|
||||||
|
|
||||||
{#snippet stickyBottom()}
|
<ModalFooter>
|
||||||
<Button shape="round" size="large" type="reset" color="secondary" fullWidth form={formId}>{$t('clear_all')}</Button>
|
<div class="flex gap-3 w-full">
|
||||||
|
<Button shape="round" size="large" type="reset" color="secondary" fullWidth form={formId}
|
||||||
|
>{$t('clear_all')}</Button
|
||||||
|
>
|
||||||
<Button shape="round" size="large" type="submit" fullWidth form={formId}>{$t('search')}</Button>
|
<Button shape="round" size="large" type="submit" fullWidth form={formId}>{$t('search')}</Button>
|
||||||
{/snippet}
|
</div>
|
||||||
</FullScreenModal>
|
</ModalFooter>
|
||||||
|
</Modal>
|
235
web/src/lib/modals/SharedLinkCreateModal.svelte
Normal file
235
web/src/lib/modals/SharedLinkCreateModal.svelte
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
||||||
|
import { SettingInputFieldType } from '$lib/constants';
|
||||||
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
|
import { SharedLinkType, createSharedLink, updateSharedLink, type SharedLinkResponseDto } from '@immich/sdk';
|
||||||
|
import { Button, Modal, ModalBody, ModalFooter } from '@immich/ui';
|
||||||
|
import { mdiLink } from '@mdi/js';
|
||||||
|
import { DateTime, Duration } from 'luxon';
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
|
import { NotificationType, notificationController } from '../components/shared-components/notification/notification';
|
||||||
|
import SettingInputField from '../components/shared-components/settings/setting-input-field.svelte';
|
||||||
|
import SettingSwitch from '../components/shared-components/settings/setting-switch.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: (sharedLink?: SharedLinkResponseDto) => void;
|
||||||
|
albumId?: string | undefined;
|
||||||
|
assetIds?: string[];
|
||||||
|
editingLink?: SharedLinkResponseDto | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { onClose, albumId = $bindable(undefined), assetIds = $bindable([]), editingLink = undefined }: Props = $props();
|
||||||
|
|
||||||
|
let sharedLink: string | null = $state(null);
|
||||||
|
let description = $state('');
|
||||||
|
let allowDownload = $state(true);
|
||||||
|
let allowUpload = $state(false);
|
||||||
|
let showMetadata = $state(true);
|
||||||
|
let expirationOption: number = $state(0);
|
||||||
|
let password = $state('');
|
||||||
|
let shouldChangeExpirationTime = $state(false);
|
||||||
|
let enablePassword = $state(false);
|
||||||
|
|
||||||
|
const expirationOptions: [number, Intl.RelativeTimeFormatUnit][] = [
|
||||||
|
[30, 'minutes'],
|
||||||
|
[1, 'hour'],
|
||||||
|
[6, 'hours'],
|
||||||
|
[1, 'day'],
|
||||||
|
[7, 'days'],
|
||||||
|
[30, 'days'],
|
||||||
|
[3, 'months'],
|
||||||
|
[1, 'year'],
|
||||||
|
];
|
||||||
|
|
||||||
|
let relativeTime = $derived(new Intl.RelativeTimeFormat($locale));
|
||||||
|
let expiredDateOptions = $derived([
|
||||||
|
{ text: $t('never'), value: 0 },
|
||||||
|
...expirationOptions.map(([value, unit]) => ({
|
||||||
|
text: relativeTime.format(value, unit),
|
||||||
|
value: Duration.fromObject({ [unit]: value }).toMillis(),
|
||||||
|
})),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let shareType = $derived(albumId ? SharedLinkType.Album : SharedLinkType.Individual);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!showMetadata) {
|
||||||
|
allowDownload = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (editingLink) {
|
||||||
|
if (editingLink.description) {
|
||||||
|
description = editingLink.description;
|
||||||
|
}
|
||||||
|
if (editingLink.password) {
|
||||||
|
password = editingLink.password;
|
||||||
|
}
|
||||||
|
allowUpload = editingLink.allowUpload;
|
||||||
|
allowDownload = editingLink.allowDownload;
|
||||||
|
showMetadata = editingLink.showMetadata;
|
||||||
|
|
||||||
|
albumId = editingLink.album?.id;
|
||||||
|
assetIds = editingLink.assets.map(({ id }) => id);
|
||||||
|
|
||||||
|
enablePassword = !!editingLink.password;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateSharedLink = async () => {
|
||||||
|
const expirationDate = expirationOption > 0 ? DateTime.now().plus(expirationOption).toISO() : undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await createSharedLink({
|
||||||
|
sharedLinkCreateDto: {
|
||||||
|
type: shareType,
|
||||||
|
albumId,
|
||||||
|
assetIds,
|
||||||
|
expiresAt: expirationDate,
|
||||||
|
allowUpload,
|
||||||
|
description,
|
||||||
|
password,
|
||||||
|
allowDownload,
|
||||||
|
showMetadata,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
onClose(data);
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, $t('errors.failed_to_create_shared_link'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditLink = async () => {
|
||||||
|
if (!editingLink) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const expirationDate = expirationOption > 0 ? DateTime.now().plus(expirationOption).toISO() : null;
|
||||||
|
|
||||||
|
await updateSharedLink({
|
||||||
|
id: editingLink.id,
|
||||||
|
sharedLinkEditDto: {
|
||||||
|
description,
|
||||||
|
password: enablePassword ? password : '',
|
||||||
|
expiresAt: shouldChangeExpirationTime ? expirationDate : undefined,
|
||||||
|
allowUpload,
|
||||||
|
allowDownload,
|
||||||
|
showMetadata,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
notificationController.show({
|
||||||
|
type: NotificationType.Info,
|
||||||
|
message: $t('edited'),
|
||||||
|
});
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, $t('errors.failed_to_edit_shared_link'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTitle = () => {
|
||||||
|
if (sharedLink) {
|
||||||
|
return $t('view_link');
|
||||||
|
}
|
||||||
|
if (editingLink) {
|
||||||
|
return $t('edit_link');
|
||||||
|
}
|
||||||
|
return $t('create_link_to_share');
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal title={getTitle()} icon={mdiLink} size="small" {onClose}>
|
||||||
|
<ModalBody>
|
||||||
|
{#if shareType === SharedLinkType.Album}
|
||||||
|
{#if !editingLink}
|
||||||
|
<div>{$t('album_with_link_access')}</div>
|
||||||
|
{:else}
|
||||||
|
<div class="text-sm">
|
||||||
|
{$t('public_album')} |
|
||||||
|
<span class="text-immich-primary dark:text-immich-dark-primary">{editingLink.album?.albumName}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if shareType === SharedLinkType.Individual}
|
||||||
|
{#if !editingLink}
|
||||||
|
<div>{$t('create_link_to_share_description')}</div>
|
||||||
|
{:else}
|
||||||
|
<div class="text-sm">
|
||||||
|
{$t('individual_share')} |
|
||||||
|
<span class="text-immich-primary dark:text-immich-dark-primary">{editingLink.description || ''}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mb-2 mt-4">
|
||||||
|
<p class="text-xs">{$t('link_options').toUpperCase()}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-gray-100 p-4 dark:bg-black/40 overflow-y-auto">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<div class="mb-2">
|
||||||
|
<SettingInputField
|
||||||
|
inputType={SettingInputFieldType.TEXT}
|
||||||
|
label={$t('description')}
|
||||||
|
bind:value={description}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-2">
|
||||||
|
<SettingInputField
|
||||||
|
inputType={SettingInputFieldType.TEXT}
|
||||||
|
label={$t('password')}
|
||||||
|
bind:value={password}
|
||||||
|
disabled={!enablePassword}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="my-3">
|
||||||
|
<SettingSwitch bind:checked={enablePassword} title={$t('require_password')} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="my-3">
|
||||||
|
<SettingSwitch bind:checked={showMetadata} title={$t('show_metadata')} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="my-3">
|
||||||
|
<SettingSwitch
|
||||||
|
bind:checked={allowDownload}
|
||||||
|
title={$t('allow_public_user_to_download')}
|
||||||
|
disabled={!showMetadata}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="my-3">
|
||||||
|
<SettingSwitch bind:checked={allowUpload} title={$t('allow_public_user_to_upload')} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if editingLink}
|
||||||
|
<div class="my-3">
|
||||||
|
<SettingSwitch bind:checked={shouldChangeExpirationTime} title={$t('change_expiration_time')} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="mt-3">
|
||||||
|
<SettingSelect
|
||||||
|
bind:value={expirationOption}
|
||||||
|
options={expiredDateOptions}
|
||||||
|
label={$t('expire_after')}
|
||||||
|
disabled={editingLink && !shouldChangeExpirationTime}
|
||||||
|
number={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalBody>
|
||||||
|
|
||||||
|
<ModalFooter>
|
||||||
|
{#if editingLink}
|
||||||
|
<Button fullWidth onclick={handleEditLink}>{$t('confirm')}</Button>
|
||||||
|
{:else}
|
||||||
|
<Button fullWidth onclick={handleCreateSharedLink}>{$t('create_link')}</Button>
|
||||||
|
{/if}
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
100
web/src/lib/modals/ShortcutsModal.svelte
Normal file
100
web/src/lib/modals/ShortcutsModal.svelte
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Modal, ModalBody } from '@immich/ui';
|
||||||
|
import { mdiInformationOutline } from '@mdi/js';
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
|
import Icon from '../components/elements/icon.svelte';
|
||||||
|
|
||||||
|
interface Shortcuts {
|
||||||
|
general: ExplainedShortcut[];
|
||||||
|
actions: ExplainedShortcut[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExplainedShortcut {
|
||||||
|
key: string[];
|
||||||
|
action: string;
|
||||||
|
info?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
shortcuts?: Shortcuts;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
onClose,
|
||||||
|
shortcuts = {
|
||||||
|
general: [
|
||||||
|
{ key: ['←', '→'], action: $t('previous_or_next_photo') },
|
||||||
|
{ key: ['x'], action: $t('select') },
|
||||||
|
{ key: ['Esc'], action: $t('back_close_deselect') },
|
||||||
|
{ key: ['Ctrl', 'k'], action: $t('search_your_photos') },
|
||||||
|
{ key: ['Ctrl', '⇧', 'k'], action: $t('open_the_search_filters') },
|
||||||
|
],
|
||||||
|
actions: [
|
||||||
|
{ key: ['f'], action: $t('favorite_or_unfavorite_photo') },
|
||||||
|
{ key: ['i'], action: $t('show_or_hide_info') },
|
||||||
|
{ key: ['s'], action: $t('stack_selected_photos') },
|
||||||
|
{ key: ['l'], action: $t('add_to_album') },
|
||||||
|
{ key: ['⇧', 'l'], action: $t('add_to_shared_album') },
|
||||||
|
{ key: ['⇧', 'a'], action: $t('archive_or_unarchive_photo') },
|
||||||
|
{ key: ['⇧', 'd'], action: $t('download') },
|
||||||
|
{ key: ['Space'], action: $t('play_or_pause_video') },
|
||||||
|
{ key: ['Del'], action: $t('trash_delete_asset'), info: $t('shift_to_permanent_delete') },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal title={$t('keyboard_shortcuts')} size="medium" {onClose}>
|
||||||
|
<ModalBody>
|
||||||
|
<div class="grid grid-cols-1 gap-4 px-4 pb-4 md:grid-cols-2">
|
||||||
|
{#if shortcuts.general.length > 0}
|
||||||
|
<div class="p-4">
|
||||||
|
<h2>{$t('general')}</h2>
|
||||||
|
<div class="text-sm">
|
||||||
|
{#each shortcuts.general as shortcut (shortcut.key.join('-'))}
|
||||||
|
<div class="grid grid-cols-[30%_70%] items-center gap-4 pt-4 text-sm">
|
||||||
|
<div class="flex justify-self-end">
|
||||||
|
{#each shortcut.key as key (key)}
|
||||||
|
<p
|
||||||
|
class="me-1 flex items-center justify-center justify-self-end rounded-lg bg-immich-primary/25 p-2"
|
||||||
|
>
|
||||||
|
{key}
|
||||||
|
</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<p class="mb-1 mt-1 flex">{shortcut.action}</p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if shortcuts.actions.length > 0}
|
||||||
|
<div class="p-4">
|
||||||
|
<h2>{$t('actions')}</h2>
|
||||||
|
<div class="text-sm">
|
||||||
|
{#each shortcuts.actions as shortcut (shortcut.key.join('-'))}
|
||||||
|
<div class="grid grid-cols-[30%_70%] items-center gap-4 pt-4 text-sm">
|
||||||
|
<div class="flex justify-self-end">
|
||||||
|
{#each shortcut.key as key (key)}
|
||||||
|
<p
|
||||||
|
class="me-1 flex items-center justify-center justify-self-end rounded-lg bg-immich-primary/25 p-2"
|
||||||
|
>
|
||||||
|
{key}
|
||||||
|
</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<p class="mb-1 mt-1 flex">{shortcut.action}</p>
|
||||||
|
{#if shortcut.info}
|
||||||
|
<Icon path={mdiInformationOutline} title={shortcut.info} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</ModalBody>
|
||||||
|
</Modal>
|
@ -6,14 +6,17 @@
|
|||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { updateUserAdmin, type UserAdminResponseDto } from '@immich/sdk';
|
import { updateUserAdmin, type UserAdminResponseDto } from '@immich/sdk';
|
||||||
import { Button, Modal, ModalBody, ModalFooter } from '@immich/ui';
|
import { Button, Modal, ModalBody, ModalFooter } from '@immich/ui';
|
||||||
import { mdiAccountEditOutline } from '@mdi/js';
|
import { mdiAccountEditOutline, mdiLockSmart, mdiOnepassword } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: UserAdminResponseDto;
|
user: UserAdminResponseDto;
|
||||||
canResetPassword?: boolean;
|
canResetPassword?: boolean;
|
||||||
onClose: (
|
onClose: (
|
||||||
data?: { action: 'update'; data: UserAdminResponseDto } | { action: 'resetPassword'; data: string },
|
data?:
|
||||||
|
| { action: 'update'; data: UserAdminResponseDto }
|
||||||
|
| { action: 'resetPassword'; data: string }
|
||||||
|
| { action: 'resetPinCode' },
|
||||||
) => void;
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -76,6 +79,24 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resetUserPincode = async () => {
|
||||||
|
const isConfirmed = await modalManager.openDialog({
|
||||||
|
prompt: $t('admin.confirm_user_pin_code_reset', { values: { user: user.name } }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isConfirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateUserAdmin({ id: user.id, userAdminUpdateDto: { pinCode: null } });
|
||||||
|
|
||||||
|
onClose({ action: 'resetPinCode' });
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, $t('errors.unable_to_reset_pin_code'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// TODO move password reset server-side
|
// TODO move password reset server-side
|
||||||
function generatePassword(length: number = 16) {
|
function generatePassword(length: number = 16) {
|
||||||
let generatedPassword = '';
|
let generatedPassword = '';
|
||||||
@ -151,13 +172,34 @@
|
|||||||
</ModalBody>
|
</ModalBody>
|
||||||
|
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
|
<div class="w-full">
|
||||||
<div class="flex gap-3 w-full">
|
<div class="flex gap-3 w-full">
|
||||||
{#if canResetPassword}
|
{#if canResetPassword}
|
||||||
<Button shape="round" color="warning" variant="filled" fullWidth onclick={resetPassword}
|
<Button
|
||||||
>{$t('reset_password')}</Button
|
shape="round"
|
||||||
|
color="warning"
|
||||||
|
variant="filled"
|
||||||
|
fullWidth
|
||||||
|
onclick={resetPassword}
|
||||||
|
leadingIcon={mdiOnepassword}
|
||||||
|
>
|
||||||
|
{$t('reset_password')}</Button
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
shape="round"
|
||||||
|
color="warning"
|
||||||
|
variant="filled"
|
||||||
|
fullWidth
|
||||||
|
onclick={resetUserPincode}
|
||||||
|
leadingIcon={mdiLockSmart}>{$t('reset_pin_code')}</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full mt-4">
|
||||||
<Button type="submit" shape="round" fullWidth form="edit-user-form">{$t('confirm')}</Button>
|
<Button type="submit" shape="round" fullWidth form="edit-user-form">{$t('confirm')}</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
@ -14,11 +14,8 @@
|
|||||||
|
|
||||||
const handleRestoreUser = async () => {
|
const handleRestoreUser = async () => {
|
||||||
try {
|
try {
|
||||||
const { deletedAt } = await restoreUserAdmin({ id: user.id });
|
await restoreUserAdmin({ id: user.id });
|
||||||
|
|
||||||
if (deletedAt === undefined) {
|
|
||||||
onClose(true);
|
onClose(true);
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, $t('errors.unable_to_restore_user'));
|
handleError(error, $t('errors.unable_to_restore_user'));
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@ import { NotificationType, notificationController } from '$lib/components/shared
|
|||||||
import { defaultLang, langs, locales } from '$lib/constants';
|
import { defaultLang, langs, locales } from '$lib/constants';
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
import { lang } from '$lib/stores/preferences.store';
|
import { lang } from '$lib/stores/preferences.store';
|
||||||
|
import { serverConfig } from '$lib/stores/server-config.store';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import {
|
import {
|
||||||
AssetJobName,
|
AssetJobName,
|
||||||
@ -256,8 +257,8 @@ export const copyToClipboard = async (secret: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const makeSharedLinkUrl = (externalDomain: string, key: string) => {
|
export const makeSharedLinkUrl = (key: string) => {
|
||||||
return new URL(`share/${key}`, externalDomain || globalThis.location.origin).href;
|
return new URL(`share/${key}`, get(serverConfig).externalDomain || globalThis.location.origin).href;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const oauth = {
|
export const oauth = {
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user