mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-04 03:27:09 -05:00 
			
		
		
		
	Merge branch 'main' into service_worker_appstatic
This commit is contained in:
		
						commit
						b6e1c00b29
					
				@ -5,22 +5,6 @@ import { app, asBearerAuth, utils } from 'src/utils';
 | 
				
			|||||||
import request from 'supertest';
 | 
					import request from 'supertest';
 | 
				
			||||||
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
 | 
					import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const invalidBirthday = [
 | 
					 | 
				
			||||||
  {
 | 
					 | 
				
			||||||
    birthDate: 'false',
 | 
					 | 
				
			||||||
    response: ['birthDate must be a string in the format yyyy-MM-dd', 'Birth date cannot be in the future'],
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
  {
 | 
					 | 
				
			||||||
    birthDate: '123567',
 | 
					 | 
				
			||||||
    response: ['birthDate must be a string in the format yyyy-MM-dd', 'Birth date cannot be in the future'],
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
  {
 | 
					 | 
				
			||||||
    birthDate: 123_567,
 | 
					 | 
				
			||||||
    response: ['birthDate must be a string in the format yyyy-MM-dd', 'Birth date cannot be in the future'],
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
  { birthDate: '9999-01-01', response: ['Birth date cannot be in the future'] },
 | 
					 | 
				
			||||||
];
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
describe('/people', () => {
 | 
					describe('/people', () => {
 | 
				
			||||||
  let admin: LoginResponseDto;
 | 
					  let admin: LoginResponseDto;
 | 
				
			||||||
  let visiblePerson: PersonResponseDto;
 | 
					  let visiblePerson: PersonResponseDto;
 | 
				
			||||||
@ -58,14 +42,6 @@ describe('/people', () => {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  describe('GET /people', () => {
 | 
					  describe('GET /people', () => {
 | 
				
			||||||
    beforeEach(async () => {});
 | 
					    beforeEach(async () => {});
 | 
				
			||||||
 | 
					 | 
				
			||||||
    it('should require authentication', async () => {
 | 
					 | 
				
			||||||
      const { status, body } = await request(app).get('/people');
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      expect(status).toBe(401);
 | 
					 | 
				
			||||||
      expect(body).toEqual(errorDto.unauthorized);
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    it('should return all people (including hidden)', async () => {
 | 
					    it('should return all people (including hidden)', async () => {
 | 
				
			||||||
      const { status, body } = await request(app)
 | 
					      const { status, body } = await request(app)
 | 
				
			||||||
        .get('/people')
 | 
					        .get('/people')
 | 
				
			||||||
@ -117,13 +93,6 @@ describe('/people', () => {
 | 
				
			|||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  describe('GET /people/:id', () => {
 | 
					  describe('GET /people/:id', () => {
 | 
				
			||||||
    it('should require authentication', async () => {
 | 
					 | 
				
			||||||
      const { status, body } = await request(app).get(`/people/${uuidDto.notFound}`);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      expect(status).toBe(401);
 | 
					 | 
				
			||||||
      expect(body).toEqual(errorDto.unauthorized);
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    it('should throw error if person with id does not exist', async () => {
 | 
					    it('should throw error if person with id does not exist', async () => {
 | 
				
			||||||
      const { status, body } = await request(app)
 | 
					      const { status, body } = await request(app)
 | 
				
			||||||
        .get(`/people/${uuidDto.notFound}`)
 | 
					        .get(`/people/${uuidDto.notFound}`)
 | 
				
			||||||
@ -144,13 +113,6 @@ describe('/people', () => {
 | 
				
			|||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  describe('GET /people/:id/statistics', () => {
 | 
					  describe('GET /people/:id/statistics', () => {
 | 
				
			||||||
    it('should require authentication', async () => {
 | 
					 | 
				
			||||||
      const { status, body } = await request(app).get(`/people/${multipleAssetsPerson.id}/statistics`);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      expect(status).toBe(401);
 | 
					 | 
				
			||||||
      expect(body).toEqual(errorDto.unauthorized);
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    it('should throw error if person with id does not exist', async () => {
 | 
					    it('should throw error if person with id does not exist', async () => {
 | 
				
			||||||
      const { status, body } = await request(app)
 | 
					      const { status, body } = await request(app)
 | 
				
			||||||
        .get(`/people/${uuidDto.notFound}/statistics`)
 | 
					        .get(`/people/${uuidDto.notFound}/statistics`)
 | 
				
			||||||
@ -171,23 +133,6 @@ describe('/people', () => {
 | 
				
			|||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  describe('POST /people', () => {
 | 
					  describe('POST /people', () => {
 | 
				
			||||||
    it('should require authentication', async () => {
 | 
					 | 
				
			||||||
      const { status, body } = await request(app).post(`/people`);
 | 
					 | 
				
			||||||
      expect(status).toBe(401);
 | 
					 | 
				
			||||||
      expect(body).toEqual(errorDto.unauthorized);
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    for (const { birthDate, response } of invalidBirthday) {
 | 
					 | 
				
			||||||
      it(`should not accept an invalid birth date [${birthDate}]`, async () => {
 | 
					 | 
				
			||||||
        const { status, body } = await request(app)
 | 
					 | 
				
			||||||
          .post(`/people`)
 | 
					 | 
				
			||||||
          .set('Authorization', `Bearer ${admin.accessToken}`)
 | 
					 | 
				
			||||||
          .send({ birthDate });
 | 
					 | 
				
			||||||
        expect(status).toBe(400);
 | 
					 | 
				
			||||||
        expect(body).toEqual(errorDto.badRequest(response));
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    it('should create a person', async () => {
 | 
					    it('should create a person', async () => {
 | 
				
			||||||
      const { status, body } = await request(app)
 | 
					      const { status, body } = await request(app)
 | 
				
			||||||
        .post(`/people`)
 | 
					        .post(`/people`)
 | 
				
			||||||
@ -223,39 +168,6 @@ describe('/people', () => {
 | 
				
			|||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  describe('PUT /people/:id', () => {
 | 
					  describe('PUT /people/:id', () => {
 | 
				
			||||||
    it('should require authentication', async () => {
 | 
					 | 
				
			||||||
      const { status, body } = await request(app).put(`/people/${uuidDto.notFound}`);
 | 
					 | 
				
			||||||
      expect(status).toBe(401);
 | 
					 | 
				
			||||||
      expect(body).toEqual(errorDto.unauthorized);
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    for (const { key, type } of [
 | 
					 | 
				
			||||||
      { key: 'name', type: 'string' },
 | 
					 | 
				
			||||||
      { key: 'featureFaceAssetId', type: 'string' },
 | 
					 | 
				
			||||||
      { key: 'isHidden', type: 'boolean value' },
 | 
					 | 
				
			||||||
      { key: 'isFavorite', type: 'boolean value' },
 | 
					 | 
				
			||||||
    ]) {
 | 
					 | 
				
			||||||
      it(`should not allow null ${key}`, async () => {
 | 
					 | 
				
			||||||
        const { status, body } = await request(app)
 | 
					 | 
				
			||||||
          .put(`/people/${visiblePerson.id}`)
 | 
					 | 
				
			||||||
          .set('Authorization', `Bearer ${admin.accessToken}`)
 | 
					 | 
				
			||||||
          .send({ [key]: null });
 | 
					 | 
				
			||||||
        expect(status).toBe(400);
 | 
					 | 
				
			||||||
        expect(body).toEqual(errorDto.badRequest([`${key} must be a ${type}`]));
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    for (const { birthDate, response } of invalidBirthday) {
 | 
					 | 
				
			||||||
      it(`should not accept an invalid birth date [${birthDate}]`, async () => {
 | 
					 | 
				
			||||||
        const { status, body } = await request(app)
 | 
					 | 
				
			||||||
          .put(`/people/${visiblePerson.id}`)
 | 
					 | 
				
			||||||
          .set('Authorization', `Bearer ${admin.accessToken}`)
 | 
					 | 
				
			||||||
          .send({ birthDate });
 | 
					 | 
				
			||||||
        expect(status).toBe(400);
 | 
					 | 
				
			||||||
        expect(body).toEqual(errorDto.badRequest(response));
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    it('should update a date of birth', async () => {
 | 
					    it('should update a date of birth', async () => {
 | 
				
			||||||
      const { status, body } = await request(app)
 | 
					      const { status, body } = await request(app)
 | 
				
			||||||
        .put(`/people/${visiblePerson.id}`)
 | 
					        .put(`/people/${visiblePerson.id}`)
 | 
				
			||||||
@ -312,12 +224,6 @@ describe('/people', () => {
 | 
				
			|||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  describe('POST /people/:id/merge', () => {
 | 
					  describe('POST /people/:id/merge', () => {
 | 
				
			||||||
    it('should require authentication', async () => {
 | 
					 | 
				
			||||||
      const { status, body } = await request(app).post(`/people/${uuidDto.notFound}/merge`);
 | 
					 | 
				
			||||||
      expect(status).toBe(401);
 | 
					 | 
				
			||||||
      expect(body).toEqual(errorDto.unauthorized);
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    it('should not supporting merging a person into themselves', async () => {
 | 
					    it('should not supporting merging a person into themselves', async () => {
 | 
				
			||||||
      const { status, body } = await request(app)
 | 
					      const { status, body } = await request(app)
 | 
				
			||||||
        .post(`/people/${visiblePerson.id}/merge`)
 | 
					        .post(`/people/${visiblePerson.id}/merge`)
 | 
				
			||||||
 | 
				
			|||||||
@ -601,6 +601,7 @@
 | 
				
			|||||||
  "cannot_undo_this_action": "You cannot undo this action!",
 | 
					  "cannot_undo_this_action": "You cannot undo this action!",
 | 
				
			||||||
  "cannot_update_the_description": "Cannot update the description",
 | 
					  "cannot_update_the_description": "Cannot update the description",
 | 
				
			||||||
  "change_date": "Change date",
 | 
					  "change_date": "Change date",
 | 
				
			||||||
 | 
					  "change_description": "Change description",
 | 
				
			||||||
  "change_display_order": "Change display order",
 | 
					  "change_display_order": "Change display order",
 | 
				
			||||||
  "change_expiration_time": "Change expiration time",
 | 
					  "change_expiration_time": "Change expiration time",
 | 
				
			||||||
  "change_location": "Change location",
 | 
					  "change_location": "Change location",
 | 
				
			||||||
@ -794,6 +795,8 @@
 | 
				
			|||||||
  "edit_avatar": "Edit avatar",
 | 
					  "edit_avatar": "Edit avatar",
 | 
				
			||||||
  "edit_date": "Edit date",
 | 
					  "edit_date": "Edit date",
 | 
				
			||||||
  "edit_date_and_time": "Edit date and time",
 | 
					  "edit_date_and_time": "Edit date and time",
 | 
				
			||||||
 | 
					  "edit_description": "Edit description",
 | 
				
			||||||
 | 
					  "edit_description_prompt": "Please select a new description:",
 | 
				
			||||||
  "edit_exclusion_pattern": "Edit exclusion pattern",
 | 
					  "edit_exclusion_pattern": "Edit exclusion pattern",
 | 
				
			||||||
  "edit_faces": "Edit faces",
 | 
					  "edit_faces": "Edit faces",
 | 
				
			||||||
  "edit_import_path": "Edit import path",
 | 
					  "edit_import_path": "Edit import path",
 | 
				
			||||||
@ -882,6 +885,7 @@
 | 
				
			|||||||
    "unable_to_archive_unarchive": "Unable to {archived, select, true {archive} other {unarchive}}",
 | 
					    "unable_to_archive_unarchive": "Unable to {archived, select, true {archive} other {unarchive}}",
 | 
				
			||||||
    "unable_to_change_album_user_role": "Unable to change the album user's role",
 | 
					    "unable_to_change_album_user_role": "Unable to change the album user's role",
 | 
				
			||||||
    "unable_to_change_date": "Unable to change date",
 | 
					    "unable_to_change_date": "Unable to change date",
 | 
				
			||||||
 | 
					    "unable_to_change_description": "Unable to change description",
 | 
				
			||||||
    "unable_to_change_favorite": "Unable to change favorite for asset",
 | 
					    "unable_to_change_favorite": "Unable to change favorite for asset",
 | 
				
			||||||
    "unable_to_change_location": "Unable to change location",
 | 
					    "unable_to_change_location": "Unable to change location",
 | 
				
			||||||
    "unable_to_change_password": "Unable to change password",
 | 
					    "unable_to_change_password": "Unable to change password",
 | 
				
			||||||
 | 
				
			|||||||
@ -8,10 +8,13 @@ FROM builder-cpu AS builder-cuda
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
FROM builder-cpu AS builder-armnn
 | 
					FROM builder-cpu AS builder-armnn
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# renovate: datasource=github-releases depName=ARM-software/armnn
 | 
				
			||||||
 | 
					ARG ARMNN_VERSION="v24.05"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
ENV ARMNN_PATH=/opt/armnn
 | 
					ENV ARMNN_PATH=/opt/armnn
 | 
				
			||||||
COPY ann /opt/ann
 | 
					COPY ann /opt/ann
 | 
				
			||||||
RUN mkdir /opt/armnn && \
 | 
					RUN mkdir /opt/armnn && \
 | 
				
			||||||
    curl -SL "https://github.com/ARM-software/armnn/releases/download/v24.05/ArmNN-linux-aarch64.tar.gz" | tar -zx -C /opt/armnn && \
 | 
					    curl -SL "https://github.com/ARM-software/armnn/releases/download/${ARMNN_VERSION}/ArmNN-linux-aarch64.tar.gz" | tar -zx -C /opt/armnn && \
 | 
				
			||||||
    cd /opt/ann && \
 | 
					    cd /opt/ann && \
 | 
				
			||||||
    sh build.sh
 | 
					    sh build.sh
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -21,6 +24,8 @@ FROM builder-cpu AS builder-rknn
 | 
				
			|||||||
# TODO: find a way to reduce the image size
 | 
					# TODO: find a way to reduce the image size
 | 
				
			||||||
FROM rocm/dev-ubuntu-22.04:6.3.4-complete@sha256:1f7e92ca7e3a3785680473329ed1091fc99db3e90fcb3a1688f2933e870ed76b AS builder-rocm
 | 
					FROM rocm/dev-ubuntu-22.04:6.3.4-complete@sha256:1f7e92ca7e3a3785680473329ed1091fc99db3e90fcb3a1688f2933e870ed76b AS builder-rocm
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# renovate: datasource=github-releases depName=Microsoft/onnxruntime
 | 
				
			||||||
 | 
					ARG ONNXRUNTIME_VERSION="v1.20.1"
 | 
				
			||||||
WORKDIR /code
 | 
					WORKDIR /code
 | 
				
			||||||
 | 
					
 | 
				
			||||||
RUN apt-get update && apt-get install -y --no-install-recommends wget git python3.10-venv
 | 
					RUN apt-get update && apt-get install -y --no-install-recommends wget git python3.10-venv
 | 
				
			||||||
@ -32,7 +37,7 @@ RUN wget -nv https://github.com/Kitware/CMake/releases/download/v3.30.1/cmake-3.
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
ENV PATH=/code/cmake-3.30.1-linux-x86_64/bin:${PATH}
 | 
					ENV PATH=/code/cmake-3.30.1-linux-x86_64/bin:${PATH}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
RUN git clone --single-branch --branch v1.20.1 --recursive "https://github.com/Microsoft/onnxruntime" onnxruntime
 | 
					RUN git clone --single-branch --branch "${ONNXRUNTIME_VERSION}" --recursive "https://github.com/Microsoft/onnxruntime" onnxruntime
 | 
				
			||||||
WORKDIR /code/onnxruntime
 | 
					WORKDIR /code/onnxruntime
 | 
				
			||||||
# Fix for multi-threading based on comments in https://github.com/microsoft/onnxruntime/pull/19567
 | 
					# Fix for multi-threading based on comments in https://github.com/microsoft/onnxruntime/pull/19567
 | 
				
			||||||
# TODO: find a way to fix this without disabling algo caching
 | 
					# TODO: find a way to fix this without disabling algo caching
 | 
				
			||||||
@ -42,7 +47,7 @@ RUN git apply /tmp/*.patch
 | 
				
			|||||||
RUN /bin/sh ./dockerfiles/scripts/install_common_deps.sh
 | 
					RUN /bin/sh ./dockerfiles/scripts/install_common_deps.sh
 | 
				
			||||||
# Note: the `parallel` setting uses a substantial amount of RAM
 | 
					# Note: the `parallel` setting uses a substantial amount of RAM
 | 
				
			||||||
RUN ./build.sh --allow_running_as_root --config Release --build_wheel --update --build --parallel 17 --cmake_extra_defines\
 | 
					RUN ./build.sh --allow_running_as_root --config Release --build_wheel --update --build --parallel 17 --cmake_extra_defines\
 | 
				
			||||||
    ONNXRUNTIME_VERSION=1.20.1 --skip_tests --use_rocm --rocm_home=/opt/rocm
 | 
					    ONNXRUNTIME_VERSION="${ONNXRUNTIME_VERSION}" --skip_tests --use_rocm --rocm_home=/opt/rocm
 | 
				
			||||||
RUN mv /code/onnxruntime/build/Linux/Release/dist/*.whl /opt/
 | 
					RUN mv /code/onnxruntime/build/Linux/Release/dist/*.whl /opt/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
FROM builder-${DEVICE} AS builder
 | 
					FROM builder-${DEVICE} AS builder
 | 
				
			||||||
@ -74,6 +79,7 @@ RUN apt-get update && \
 | 
				
			|||||||
    wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17384.11/intel-igc-core_1.0.17384.11_amd64.deb && \
 | 
					    wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17384.11/intel-igc-core_1.0.17384.11_amd64.deb && \
 | 
				
			||||||
    wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17384.11/intel-igc-opencl_1.0.17384.11_amd64.deb && \
 | 
					    wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17384.11/intel-igc-opencl_1.0.17384.11_amd64.deb && \
 | 
				
			||||||
    wget -nv https://github.com/intel/compute-runtime/releases/download/24.31.30508.7/intel-opencl-icd_24.31.30508.7_amd64.deb && \
 | 
					    wget -nv https://github.com/intel/compute-runtime/releases/download/24.31.30508.7/intel-opencl-icd_24.31.30508.7_amd64.deb && \
 | 
				
			||||||
 | 
					    # TODO: Figure out how to get renovate to manage this differently versioned libigdgmm file
 | 
				
			||||||
    wget -nv https://github.com/intel/compute-runtime/releases/download/24.31.30508.7/libigdgmm12_22.4.1_amd64.deb && \
 | 
					    wget -nv https://github.com/intel/compute-runtime/releases/download/24.31.30508.7/libigdgmm12_22.4.1_amd64.deb && \
 | 
				
			||||||
    dpkg -i *.deb && \
 | 
					    dpkg -i *.deb && \
 | 
				
			||||||
    rm *.deb && \
 | 
					    rm *.deb && \
 | 
				
			||||||
@ -118,9 +124,12 @@ COPY --from=builder-armnn \
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
FROM prod-cpu AS prod-rknn
 | 
					FROM prod-cpu AS prod-rknn
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# renovate: datasource=github-tags depName=airockchip/rknn-toolkit2
 | 
				
			||||||
 | 
					ARG RKNN_TOOLKIT_VERSION="v2.3.0"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2
 | 
					ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2
 | 
				
			||||||
 | 
					
 | 
				
			||||||
ADD --checksum=sha256:73993ed4b440460825f21611731564503cc1d5a0c123746477da6cd574f34885 https://github.com/airockchip/rknn-toolkit2/raw/refs/tags/v2.3.0/rknpu2/runtime/Linux/librknn_api/aarch64/librknnrt.so /usr/lib/
 | 
					ADD --checksum=sha256:73993ed4b440460825f21611731564503cc1d5a0c123746477da6cd574f34885 "https://github.com/airockchip/rknn-toolkit2/raw/refs/tags/${RKNN_TOOLKIT_VERSION}/rknpu2/runtime/Linux/librknn_api/aarch64/librknnrt.so" /usr/lib/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
FROM prod-${DEVICE} AS prod
 | 
					FROM prod-${DEVICE} AS prod
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										19
									
								
								mobile/openapi/lib/model/asset_bulk_update_dto.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										19
									
								
								mobile/openapi/lib/model/asset_bulk_update_dto.dart
									
									
									
										generated
									
									
									
								
							@ -14,6 +14,7 @@ class AssetBulkUpdateDto {
 | 
				
			|||||||
  /// Returns a new [AssetBulkUpdateDto] instance.
 | 
					  /// Returns a new [AssetBulkUpdateDto] instance.
 | 
				
			||||||
  AssetBulkUpdateDto({
 | 
					  AssetBulkUpdateDto({
 | 
				
			||||||
    this.dateTimeOriginal,
 | 
					    this.dateTimeOriginal,
 | 
				
			||||||
 | 
					    this.description,
 | 
				
			||||||
    this.duplicateId,
 | 
					    this.duplicateId,
 | 
				
			||||||
    this.ids = const [],
 | 
					    this.ids = const [],
 | 
				
			||||||
    this.isFavorite,
 | 
					    this.isFavorite,
 | 
				
			||||||
@ -31,6 +32,14 @@ class AssetBulkUpdateDto {
 | 
				
			|||||||
  ///
 | 
					  ///
 | 
				
			||||||
  String? dateTimeOriginal;
 | 
					  String? dateTimeOriginal;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  ///
 | 
				
			||||||
 | 
					  /// 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? description;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  String? duplicateId;
 | 
					  String? duplicateId;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  List<String> ids;
 | 
					  List<String> ids;
 | 
				
			||||||
@ -80,6 +89,7 @@ class AssetBulkUpdateDto {
 | 
				
			|||||||
  @override
 | 
					  @override
 | 
				
			||||||
  bool operator ==(Object other) => identical(this, other) || other is AssetBulkUpdateDto &&
 | 
					  bool operator ==(Object other) => identical(this, other) || other is AssetBulkUpdateDto &&
 | 
				
			||||||
    other.dateTimeOriginal == dateTimeOriginal &&
 | 
					    other.dateTimeOriginal == dateTimeOriginal &&
 | 
				
			||||||
 | 
					    other.description == description &&
 | 
				
			||||||
    other.duplicateId == duplicateId &&
 | 
					    other.duplicateId == duplicateId &&
 | 
				
			||||||
    _deepEquality.equals(other.ids, ids) &&
 | 
					    _deepEquality.equals(other.ids, ids) &&
 | 
				
			||||||
    other.isFavorite == isFavorite &&
 | 
					    other.isFavorite == isFavorite &&
 | 
				
			||||||
@ -92,6 +102,7 @@ class AssetBulkUpdateDto {
 | 
				
			|||||||
  int get hashCode =>
 | 
					  int get hashCode =>
 | 
				
			||||||
    // ignore: unnecessary_parenthesis
 | 
					    // ignore: unnecessary_parenthesis
 | 
				
			||||||
    (dateTimeOriginal == null ? 0 : dateTimeOriginal!.hashCode) +
 | 
					    (dateTimeOriginal == null ? 0 : dateTimeOriginal!.hashCode) +
 | 
				
			||||||
 | 
					    (description == null ? 0 : description!.hashCode) +
 | 
				
			||||||
    (duplicateId == null ? 0 : duplicateId!.hashCode) +
 | 
					    (duplicateId == null ? 0 : duplicateId!.hashCode) +
 | 
				
			||||||
    (ids.hashCode) +
 | 
					    (ids.hashCode) +
 | 
				
			||||||
    (isFavorite == null ? 0 : isFavorite!.hashCode) +
 | 
					    (isFavorite == null ? 0 : isFavorite!.hashCode) +
 | 
				
			||||||
@ -101,7 +112,7 @@ class AssetBulkUpdateDto {
 | 
				
			|||||||
    (visibility == null ? 0 : visibility!.hashCode);
 | 
					    (visibility == null ? 0 : visibility!.hashCode);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @override
 | 
					  @override
 | 
				
			||||||
  String toString() => 'AssetBulkUpdateDto[dateTimeOriginal=$dateTimeOriginal, duplicateId=$duplicateId, ids=$ids, isFavorite=$isFavorite, latitude=$latitude, longitude=$longitude, rating=$rating, visibility=$visibility]';
 | 
					  String toString() => 'AssetBulkUpdateDto[dateTimeOriginal=$dateTimeOriginal, description=$description, duplicateId=$duplicateId, ids=$ids, isFavorite=$isFavorite, latitude=$latitude, longitude=$longitude, rating=$rating, visibility=$visibility]';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Map<String, dynamic> toJson() {
 | 
					  Map<String, dynamic> toJson() {
 | 
				
			||||||
    final json = <String, dynamic>{};
 | 
					    final json = <String, dynamic>{};
 | 
				
			||||||
@ -110,6 +121,11 @@ class AssetBulkUpdateDto {
 | 
				
			|||||||
    } else {
 | 
					    } else {
 | 
				
			||||||
    //  json[r'dateTimeOriginal'] = null;
 | 
					    //  json[r'dateTimeOriginal'] = null;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					    if (this.description != null) {
 | 
				
			||||||
 | 
					      json[r'description'] = this.description;
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					    //  json[r'description'] = null;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
    if (this.duplicateId != null) {
 | 
					    if (this.duplicateId != null) {
 | 
				
			||||||
      json[r'duplicateId'] = this.duplicateId;
 | 
					      json[r'duplicateId'] = this.duplicateId;
 | 
				
			||||||
    } else {
 | 
					    } else {
 | 
				
			||||||
@ -154,6 +170,7 @@ class AssetBulkUpdateDto {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
      return AssetBulkUpdateDto(
 | 
					      return AssetBulkUpdateDto(
 | 
				
			||||||
        dateTimeOriginal: mapValueOfType<String>(json, r'dateTimeOriginal'),
 | 
					        dateTimeOriginal: mapValueOfType<String>(json, r'dateTimeOriginal'),
 | 
				
			||||||
 | 
					        description: mapValueOfType<String>(json, r'description'),
 | 
				
			||||||
        duplicateId: mapValueOfType<String>(json, r'duplicateId'),
 | 
					        duplicateId: mapValueOfType<String>(json, r'duplicateId'),
 | 
				
			||||||
        ids: json[r'ids'] is Iterable
 | 
					        ids: json[r'ids'] is Iterable
 | 
				
			||||||
            ? (json[r'ids'] as Iterable).cast<String>().toList(growable: false)
 | 
					            ? (json[r'ids'] as Iterable).cast<String>().toList(growable: false)
 | 
				
			||||||
 | 
				
			|||||||
@ -8605,6 +8605,9 @@
 | 
				
			|||||||
          "dateTimeOriginal": {
 | 
					          "dateTimeOriginal": {
 | 
				
			||||||
            "type": "string"
 | 
					            "type": "string"
 | 
				
			||||||
          },
 | 
					          },
 | 
				
			||||||
 | 
					          "description": {
 | 
				
			||||||
 | 
					            "type": "string"
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
          "duplicateId": {
 | 
					          "duplicateId": {
 | 
				
			||||||
            "nullable": true,
 | 
					            "nullable": true,
 | 
				
			||||||
            "type": "string"
 | 
					            "type": "string"
 | 
				
			||||||
@ -11075,6 +11078,7 @@
 | 
				
			|||||||
          },
 | 
					          },
 | 
				
			||||||
          "featureFaceAssetId": {
 | 
					          "featureFaceAssetId": {
 | 
				
			||||||
            "description": "Asset is used to get the feature face thumbnail.",
 | 
					            "description": "Asset is used to get the feature face thumbnail.",
 | 
				
			||||||
 | 
					            "format": "uuid",
 | 
				
			||||||
            "type": "string"
 | 
					            "type": "string"
 | 
				
			||||||
          },
 | 
					          },
 | 
				
			||||||
          "id": {
 | 
					          "id": {
 | 
				
			||||||
@ -11280,6 +11284,7 @@
 | 
				
			|||||||
          },
 | 
					          },
 | 
				
			||||||
          "featureFaceAssetId": {
 | 
					          "featureFaceAssetId": {
 | 
				
			||||||
            "description": "Asset is used to get the feature face thumbnail.",
 | 
					            "description": "Asset is used to get the feature face thumbnail.",
 | 
				
			||||||
 | 
					            "format": "uuid",
 | 
				
			||||||
            "type": "string"
 | 
					            "type": "string"
 | 
				
			||||||
          },
 | 
					          },
 | 
				
			||||||
          "isFavorite": {
 | 
					          "isFavorite": {
 | 
				
			||||||
 | 
				
			|||||||
@ -431,6 +431,7 @@ export type AssetMediaResponseDto = {
 | 
				
			|||||||
};
 | 
					};
 | 
				
			||||||
export type AssetBulkUpdateDto = {
 | 
					export type AssetBulkUpdateDto = {
 | 
				
			||||||
    dateTimeOriginal?: string;
 | 
					    dateTimeOriginal?: string;
 | 
				
			||||||
 | 
					    description?: string;
 | 
				
			||||||
    duplicateId?: string | null;
 | 
					    duplicateId?: string | null;
 | 
				
			||||||
    ids: string[];
 | 
					    ids: string[];
 | 
				
			||||||
    isFavorite?: boolean;
 | 
					    isFavorite?: boolean;
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										172
									
								
								server/src/controllers/person.controller.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										172
									
								
								server/src/controllers/person.controller.spec.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,172 @@
 | 
				
			|||||||
 | 
					import { PersonController } from 'src/controllers/person.controller';
 | 
				
			||||||
 | 
					import { LoggingRepository } from 'src/repositories/logging.repository';
 | 
				
			||||||
 | 
					import { PersonService } from 'src/services/person.service';
 | 
				
			||||||
 | 
					import request from 'supertest';
 | 
				
			||||||
 | 
					import { errorDto } from 'test/medium/responses';
 | 
				
			||||||
 | 
					import { factory } from 'test/small.factory';
 | 
				
			||||||
 | 
					import { automock, ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					describe(PersonController.name, () => {
 | 
				
			||||||
 | 
					  let ctx: ControllerContext;
 | 
				
			||||||
 | 
					  const service = mockBaseService(PersonService);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  beforeAll(async () => {
 | 
				
			||||||
 | 
					    ctx = await controllerSetup(PersonController, [
 | 
				
			||||||
 | 
					      { provide: PersonService, useValue: service },
 | 
				
			||||||
 | 
					      { provide: LoggingRepository, useValue: automock(LoggingRepository, { strict: false }) },
 | 
				
			||||||
 | 
					    ]);
 | 
				
			||||||
 | 
					    return () => ctx.close();
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  beforeEach(() => {
 | 
				
			||||||
 | 
					    service.resetAllMocks();
 | 
				
			||||||
 | 
					    ctx.reset();
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  describe('GET /people', () => {
 | 
				
			||||||
 | 
					    it('should be an authenticated route', async () => {
 | 
				
			||||||
 | 
					      await request(ctx.getHttpServer()).get('/people');
 | 
				
			||||||
 | 
					      expect(ctx.authenticate).toHaveBeenCalled();
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it(`should require closestPersonId to be a uuid`, async () => {
 | 
				
			||||||
 | 
					      const { status, body } = await request(ctx.getHttpServer())
 | 
				
			||||||
 | 
					        .get(`/people`)
 | 
				
			||||||
 | 
					        .query({ closestPersonId: 'invalid' })
 | 
				
			||||||
 | 
					        .set('Authorization', `Bearer token`);
 | 
				
			||||||
 | 
					      expect(status).toBe(400);
 | 
				
			||||||
 | 
					      expect(body).toEqual(errorDto.badRequest([expect.stringContaining('must be a UUID')]));
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it(`should require closestAssetId to be a uuid`, async () => {
 | 
				
			||||||
 | 
					      const { status, body } = await request(ctx.getHttpServer())
 | 
				
			||||||
 | 
					        .get(`/people`)
 | 
				
			||||||
 | 
					        .query({ closestAssetId: 'invalid' })
 | 
				
			||||||
 | 
					        .set('Authorization', `Bearer token`);
 | 
				
			||||||
 | 
					      expect(status).toBe(400);
 | 
				
			||||||
 | 
					      expect(body).toEqual(errorDto.badRequest([expect.stringContaining('must be a UUID')]));
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  describe('POST /people', () => {
 | 
				
			||||||
 | 
					    it('should be an authenticated route', async () => {
 | 
				
			||||||
 | 
					      await request(ctx.getHttpServer()).post('/people');
 | 
				
			||||||
 | 
					      expect(ctx.authenticate).toHaveBeenCalled();
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it('should map an empty birthDate to null', async () => {
 | 
				
			||||||
 | 
					      await request(ctx.getHttpServer()).post('/people').send({ birthDate: '' });
 | 
				
			||||||
 | 
					      expect(service.create).toHaveBeenCalledWith(undefined, { birthDate: null });
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  describe('GET /people/:id', () => {
 | 
				
			||||||
 | 
					    it('should be an authenticated route', async () => {
 | 
				
			||||||
 | 
					      await request(ctx.getHttpServer()).get(`/people/${factory.uuid()}`);
 | 
				
			||||||
 | 
					      expect(ctx.authenticate).toHaveBeenCalled();
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  describe('PUT /people/:id', () => {
 | 
				
			||||||
 | 
					    it('should be an authenticated route', async () => {
 | 
				
			||||||
 | 
					      await request(ctx.getHttpServer()).get(`/people/${factory.uuid()}`);
 | 
				
			||||||
 | 
					      expect(ctx.authenticate).toHaveBeenCalled();
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it('should require a valid uuid', async () => {
 | 
				
			||||||
 | 
					      const { status, body } = await request(ctx.getHttpServer()).put(`/people/123`);
 | 
				
			||||||
 | 
					      expect(status).toBe(400);
 | 
				
			||||||
 | 
					      expect(body).toEqual(errorDto.badRequest([expect.stringContaining('id must be a UUID')]));
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it(`should not allow a null name`, async () => {
 | 
				
			||||||
 | 
					      const { status, body } = await request(ctx.getHttpServer())
 | 
				
			||||||
 | 
					        .post(`/people`)
 | 
				
			||||||
 | 
					        .send({ name: null })
 | 
				
			||||||
 | 
					        .set('Authorization', `Bearer token`);
 | 
				
			||||||
 | 
					      expect(status).toBe(400);
 | 
				
			||||||
 | 
					      expect(body).toEqual(errorDto.badRequest(['name must be a string']));
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it(`should require featureFaceAssetId to be a uuid`, async () => {
 | 
				
			||||||
 | 
					      const { status, body } = await request(ctx.getHttpServer())
 | 
				
			||||||
 | 
					        .put(`/people/${factory.uuid()}`)
 | 
				
			||||||
 | 
					        .send({ featureFaceAssetId: 'invalid' })
 | 
				
			||||||
 | 
					        .set('Authorization', `Bearer token`);
 | 
				
			||||||
 | 
					      expect(status).toBe(400);
 | 
				
			||||||
 | 
					      expect(body).toEqual(errorDto.badRequest(['featureFaceAssetId must be a UUID']));
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it(`should require isFavorite to be a boolean`, async () => {
 | 
				
			||||||
 | 
					      const { status, body } = await request(ctx.getHttpServer())
 | 
				
			||||||
 | 
					        .put(`/people/${factory.uuid()}`)
 | 
				
			||||||
 | 
					        .send({ isFavorite: 'invalid' })
 | 
				
			||||||
 | 
					        .set('Authorization', `Bearer token`);
 | 
				
			||||||
 | 
					      expect(status).toBe(400);
 | 
				
			||||||
 | 
					      expect(body).toEqual(errorDto.badRequest(['isFavorite must be a boolean value']));
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it(`should require isHidden to be a boolean`, async () => {
 | 
				
			||||||
 | 
					      const { status, body } = await request(ctx.getHttpServer())
 | 
				
			||||||
 | 
					        .put(`/people/${factory.uuid()}`)
 | 
				
			||||||
 | 
					        .send({ isHidden: 'invalid' })
 | 
				
			||||||
 | 
					        .set('Authorization', `Bearer token`);
 | 
				
			||||||
 | 
					      expect(status).toBe(400);
 | 
				
			||||||
 | 
					      expect(body).toEqual(errorDto.badRequest(['isHidden must be a boolean value']));
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it('should map an empty birthDate to null', async () => {
 | 
				
			||||||
 | 
					      const id = factory.uuid();
 | 
				
			||||||
 | 
					      await request(ctx.getHttpServer()).put(`/people/${id}`).send({ birthDate: '' });
 | 
				
			||||||
 | 
					      expect(service.update).toHaveBeenCalledWith(undefined, id, { birthDate: null });
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it('should not accept an invalid birth date (false)', async () => {
 | 
				
			||||||
 | 
					      const { status, body } = await request(ctx.getHttpServer())
 | 
				
			||||||
 | 
					        .put(`/people/${factory.uuid()}`)
 | 
				
			||||||
 | 
					        .send({ birthDate: false });
 | 
				
			||||||
 | 
					      expect(status).toBe(400);
 | 
				
			||||||
 | 
					      expect(body).toEqual(
 | 
				
			||||||
 | 
					        errorDto.badRequest([
 | 
				
			||||||
 | 
					          'birthDate must be a string in the format yyyy-MM-dd',
 | 
				
			||||||
 | 
					          'Birth date cannot be in the future',
 | 
				
			||||||
 | 
					        ]),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it('should not accept an invalid birth date (number)', async () => {
 | 
				
			||||||
 | 
					      const { status, body } = await request(ctx.getHttpServer())
 | 
				
			||||||
 | 
					        .put(`/people/${factory.uuid()}`)
 | 
				
			||||||
 | 
					        .send({ birthDate: 123_456 });
 | 
				
			||||||
 | 
					      expect(status).toBe(400);
 | 
				
			||||||
 | 
					      expect(body).toEqual(
 | 
				
			||||||
 | 
					        errorDto.badRequest([
 | 
				
			||||||
 | 
					          'birthDate must be a string in the format yyyy-MM-dd',
 | 
				
			||||||
 | 
					          'Birth date cannot be in the future',
 | 
				
			||||||
 | 
					        ]),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it('should not accept a birth date in the future)', async () => {
 | 
				
			||||||
 | 
					      const { status, body } = await request(ctx.getHttpServer())
 | 
				
			||||||
 | 
					        .put(`/people/${factory.uuid()}`)
 | 
				
			||||||
 | 
					        .send({ birthDate: '9999-01-01' });
 | 
				
			||||||
 | 
					      expect(status).toBe(400);
 | 
				
			||||||
 | 
					      expect(body).toEqual(errorDto.badRequest(['Birth date cannot be in the future']));
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  describe('POST /people/:id/merge', () => {
 | 
				
			||||||
 | 
					    it('should be an authenticated route', async () => {
 | 
				
			||||||
 | 
					      await request(ctx.getHttpServer()).post(`/people/${factory.uuid()}/merge`);
 | 
				
			||||||
 | 
					      expect(ctx.authenticate).toHaveBeenCalled();
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  describe('GET /people/:id/statistics', () => {
 | 
				
			||||||
 | 
					    it('should be an authenticated route', async () => {
 | 
				
			||||||
 | 
					      await request(ctx.getHttpServer()).get(`/people/${factory.uuid()}/statistics`);
 | 
				
			||||||
 | 
					      expect(ctx.authenticate).toHaveBeenCalled();
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
@ -27,7 +27,9 @@ export class PersonController {
 | 
				
			|||||||
  constructor(
 | 
					  constructor(
 | 
				
			||||||
    private service: PersonService,
 | 
					    private service: PersonService,
 | 
				
			||||||
    private logger: LoggingRepository,
 | 
					    private logger: LoggingRepository,
 | 
				
			||||||
  ) {}
 | 
					  ) {
 | 
				
			||||||
 | 
					    this.logger.setContext(PersonController.name);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @Get()
 | 
					  @Get()
 | 
				
			||||||
  @Authenticated({ permission: Permission.PERSON_READ })
 | 
					  @Authenticated({ permission: Permission.PERSON_READ })
 | 
				
			||||||
 | 
				
			|||||||
@ -54,6 +54,10 @@ export class UpdateAssetBase {
 | 
				
			|||||||
  @Max(5)
 | 
					  @Max(5)
 | 
				
			||||||
  @Min(-1)
 | 
					  @Min(-1)
 | 
				
			||||||
  rating?: number;
 | 
					  rating?: number;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @Optional()
 | 
				
			||||||
 | 
					  @IsString()
 | 
				
			||||||
 | 
					  description?: string;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export class AssetBulkUpdateDto extends UpdateAssetBase {
 | 
					export class AssetBulkUpdateDto extends UpdateAssetBase {
 | 
				
			||||||
@ -65,10 +69,6 @@ export class AssetBulkUpdateDto extends UpdateAssetBase {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export class UpdateAssetDto extends UpdateAssetBase {
 | 
					export class UpdateAssetDto extends UpdateAssetBase {
 | 
				
			||||||
  @Optional()
 | 
					 | 
				
			||||||
  @IsString()
 | 
					 | 
				
			||||||
  description?: string;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  @ValidateUUID({ optional: true, nullable: true })
 | 
					  @ValidateUUID({ optional: true, nullable: true })
 | 
				
			||||||
  livePhotoVideoId?: string | null;
 | 
					  livePhotoVideoId?: string | null;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -33,7 +33,7 @@ export class PersonCreateDto {
 | 
				
			|||||||
  @ApiProperty({ format: 'date' })
 | 
					  @ApiProperty({ format: 'date' })
 | 
				
			||||||
  @MaxDateString(() => DateTime.now(), { message: 'Birth date cannot be in the future' })
 | 
					  @MaxDateString(() => DateTime.now(), { message: 'Birth date cannot be in the future' })
 | 
				
			||||||
  @IsDateStringFormat('yyyy-MM-dd')
 | 
					  @IsDateStringFormat('yyyy-MM-dd')
 | 
				
			||||||
  @Optional({ nullable: true })
 | 
					  @Optional({ nullable: true, emptyToNull: true })
 | 
				
			||||||
  birthDate?: Date | null;
 | 
					  birthDate?: Date | null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  /**
 | 
					  /**
 | 
				
			||||||
@ -54,8 +54,7 @@ export class PersonUpdateDto extends PersonCreateDto {
 | 
				
			|||||||
  /**
 | 
					  /**
 | 
				
			||||||
   * Asset is used to get the feature face thumbnail.
 | 
					   * Asset is used to get the feature face thumbnail.
 | 
				
			||||||
   */
 | 
					   */
 | 
				
			||||||
  @Optional()
 | 
					  @ValidateUUID({ optional: true })
 | 
				
			||||||
  @IsString()
 | 
					 | 
				
			||||||
  featureFaceAssetId?: string;
 | 
					  featureFaceAssetId?: string;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -567,6 +567,7 @@ export enum DatabaseLock {
 | 
				
			|||||||
  Library = 1337,
 | 
					  Library = 1337,
 | 
				
			||||||
  GetSystemConfig = 69,
 | 
					  GetSystemConfig = 69,
 | 
				
			||||||
  BackupDatabase = 42,
 | 
					  BackupDatabase = 42,
 | 
				
			||||||
 | 
					  MemoryCreation = 777,
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export enum SyncRequestType {
 | 
					export enum SyncRequestType {
 | 
				
			||||||
 | 
				
			|||||||
@ -108,13 +108,21 @@ export class AssetService extends BaseService {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async updateAll(auth: AuthDto, dto: AssetBulkUpdateDto): Promise<void> {
 | 
					  async updateAll(auth: AuthDto, dto: AssetBulkUpdateDto): Promise<void> {
 | 
				
			||||||
    const { ids, dateTimeOriginal, latitude, longitude, ...options } = dto;
 | 
					    const { ids, description, dateTimeOriginal, latitude, longitude, ...options } = dto;
 | 
				
			||||||
    await this.requireAccess({ auth, permission: Permission.ASSET_UPDATE, ids });
 | 
					    await this.requireAccess({ auth, permission: Permission.ASSET_UPDATE, ids });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (dateTimeOriginal !== undefined || latitude !== undefined || longitude !== undefined) {
 | 
					    if (
 | 
				
			||||||
      await this.assetRepository.updateAllExif(ids, { dateTimeOriginal, latitude, longitude });
 | 
					      description !== undefined ||
 | 
				
			||||||
 | 
					      dateTimeOriginal !== undefined ||
 | 
				
			||||||
 | 
					      latitude !== undefined ||
 | 
				
			||||||
 | 
					      longitude !== undefined
 | 
				
			||||||
 | 
					    ) {
 | 
				
			||||||
 | 
					      await this.assetRepository.updateAllExif(ids, { description, dateTimeOriginal, latitude, longitude });
 | 
				
			||||||
      await this.jobRepository.queueAll(
 | 
					      await this.jobRepository.queueAll(
 | 
				
			||||||
        ids.map((id) => ({ name: JobName.SIDECAR_WRITE, data: { id, dateTimeOriginal, latitude, longitude } })),
 | 
					        ids.map((id) => ({
 | 
				
			||||||
 | 
					          name: JobName.SIDECAR_WRITE,
 | 
				
			||||||
 | 
					          data: { id, description, dateTimeOriginal, latitude, longitude },
 | 
				
			||||||
 | 
					        })),
 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -4,9 +4,8 @@ import { OnJob } from 'src/decorators';
 | 
				
			|||||||
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
 | 
					import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
 | 
				
			||||||
import { AuthDto } from 'src/dtos/auth.dto';
 | 
					import { AuthDto } from 'src/dtos/auth.dto';
 | 
				
			||||||
import { MemoryCreateDto, MemoryResponseDto, MemorySearchDto, MemoryUpdateDto, mapMemory } from 'src/dtos/memory.dto';
 | 
					import { MemoryCreateDto, MemoryResponseDto, MemorySearchDto, MemoryUpdateDto, mapMemory } from 'src/dtos/memory.dto';
 | 
				
			||||||
import { JobName, MemoryType, Permission, QueueName, SystemMetadataKey } from 'src/enum';
 | 
					import { DatabaseLock, JobName, MemoryType, Permission, QueueName, SystemMetadataKey } from 'src/enum';
 | 
				
			||||||
import { BaseService } from 'src/services/base.service';
 | 
					import { BaseService } from 'src/services/base.service';
 | 
				
			||||||
import { OnThisDayData } from 'src/types';
 | 
					 | 
				
			||||||
import { addAssets, getMyPartnerIds, removeAssets } from 'src/utils/asset.util';
 | 
					import { addAssets, getMyPartnerIds, removeAssets } from 'src/utils/asset.util';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const DAYS = 3;
 | 
					const DAYS = 3;
 | 
				
			||||||
@ -16,55 +15,61 @@ export class MemoryService extends BaseService {
 | 
				
			|||||||
  @OnJob({ name: JobName.MEMORIES_CREATE, queue: QueueName.BACKGROUND_TASK })
 | 
					  @OnJob({ name: JobName.MEMORIES_CREATE, queue: QueueName.BACKGROUND_TASK })
 | 
				
			||||||
  async onMemoriesCreate() {
 | 
					  async onMemoriesCreate() {
 | 
				
			||||||
    const users = await this.userRepository.getList({ withDeleted: false });
 | 
					    const users = await this.userRepository.getList({ withDeleted: false });
 | 
				
			||||||
    const userMap: Record<string, string[]> = {};
 | 
					    const usersIds = await Promise.all(
 | 
				
			||||||
    for (const user of users) {
 | 
					      users.map((user) =>
 | 
				
			||||||
      const partnerIds = await getMyPartnerIds({
 | 
					        getMyPartnerIds({
 | 
				
			||||||
        userId: user.id,
 | 
					          userId: user.id,
 | 
				
			||||||
        repository: this.partnerRepository,
 | 
					          repository: this.partnerRepository,
 | 
				
			||||||
        timelineEnabled: true,
 | 
					          timelineEnabled: true,
 | 
				
			||||||
      });
 | 
					        }),
 | 
				
			||||||
      userMap[user.id] = [user.id, ...partnerIds];
 | 
					      ),
 | 
				
			||||||
    }
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const start = DateTime.utc().startOf('day').minus({ days: DAYS });
 | 
					    await this.databaseRepository.withLock(DatabaseLock.MemoryCreation, async () => {
 | 
				
			||||||
 | 
					      const state = await this.systemMetadataRepository.get(SystemMetadataKey.MEMORIES_STATE);
 | 
				
			||||||
 | 
					      const start = DateTime.utc().startOf('day').minus({ days: DAYS });
 | 
				
			||||||
 | 
					      const lastOnThisDayDate = state?.lastOnThisDayDate ? DateTime.fromISO(state.lastOnThisDayDate) : start;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const state = await this.systemMetadataRepository.get(SystemMetadataKey.MEMORIES_STATE);
 | 
					      // generate a memory +/- X days from today
 | 
				
			||||||
    const lastOnThisDayDate = state?.lastOnThisDayDate ? DateTime.fromISO(state.lastOnThisDayDate) : start;
 | 
					      for (let i = 0; i <= DAYS * 2; i++) {
 | 
				
			||||||
 | 
					        const target = start.plus({ days: i });
 | 
				
			||||||
    // generate a memory +/- X days from today
 | 
					        if (lastOnThisDayDate >= target) {
 | 
				
			||||||
    for (let i = 0; i <= DAYS * 2; i++) {
 | 
					          continue;
 | 
				
			||||||
      const target = start.plus({ days: i });
 | 
					 | 
				
			||||||
      if (lastOnThisDayDate >= target) {
 | 
					 | 
				
			||||||
        continue;
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      const showAt = target.startOf('day').toISO();
 | 
					 | 
				
			||||||
      const hideAt = target.endOf('day').toISO();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      for (const [userId, userIds] of Object.entries(userMap)) {
 | 
					 | 
				
			||||||
        const memories = await this.assetRepository.getByDayOfYear(userIds, target);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        for (const { year, assets } of memories) {
 | 
					 | 
				
			||||||
          const data: OnThisDayData = { year };
 | 
					 | 
				
			||||||
          await this.memoryRepository.create(
 | 
					 | 
				
			||||||
            {
 | 
					 | 
				
			||||||
              ownerId: userId,
 | 
					 | 
				
			||||||
              type: MemoryType.ON_THIS_DAY,
 | 
					 | 
				
			||||||
              data,
 | 
					 | 
				
			||||||
              memoryAt: target.set({ year }).toISO(),
 | 
					 | 
				
			||||||
              showAt,
 | 
					 | 
				
			||||||
              hideAt,
 | 
					 | 
				
			||||||
            },
 | 
					 | 
				
			||||||
            new Set(assets.map(({ id }) => id)),
 | 
					 | 
				
			||||||
          );
 | 
					 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
      await this.systemMetadataRepository.set(SystemMetadataKey.MEMORIES_STATE, {
 | 
					        try {
 | 
				
			||||||
        ...state,
 | 
					          await Promise.all(users.map((owner, i) => this.createOnThisDayMemories(owner.id, usersIds[i], target)));
 | 
				
			||||||
        lastOnThisDayDate: target.toISO(),
 | 
					        } catch (error) {
 | 
				
			||||||
      });
 | 
					          this.logger.error(`Failed to create memories for ${target.toISO()}`, error);
 | 
				
			||||||
    }
 | 
					        }
 | 
				
			||||||
 | 
					        // update system metadata even when there is an error to minimize the chance of duplicates
 | 
				
			||||||
 | 
					        await this.systemMetadataRepository.set(SystemMetadataKey.MEMORIES_STATE, {
 | 
				
			||||||
 | 
					          ...state,
 | 
				
			||||||
 | 
					          lastOnThisDayDate: target.toISO(),
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private async createOnThisDayMemories(ownerId: string, userIds: string[], target: DateTime) {
 | 
				
			||||||
 | 
					    const showAt = target.startOf('day').toISO();
 | 
				
			||||||
 | 
					    const hideAt = target.endOf('day').toISO();
 | 
				
			||||||
 | 
					    const memories = await this.assetRepository.getByDayOfYear([ownerId, ...userIds], target);
 | 
				
			||||||
 | 
					    await Promise.all(
 | 
				
			||||||
 | 
					      memories.map(({ year, assets }) =>
 | 
				
			||||||
 | 
					        this.memoryRepository.create(
 | 
				
			||||||
 | 
					          {
 | 
				
			||||||
 | 
					            ownerId,
 | 
				
			||||||
 | 
					            type: MemoryType.ON_THIS_DAY,
 | 
				
			||||||
 | 
					            data: { year },
 | 
				
			||||||
 | 
					            memoryAt: target.set({ year }).toISO()!,
 | 
				
			||||||
 | 
					            showAt,
 | 
				
			||||||
 | 
					            hideAt,
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          new Set(assets.map(({ id }) => id)),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @OnJob({ name: JobName.MEMORIES_CLEANUP, queue: QueueName.BACKGROUND_TASK })
 | 
					  @OnJob({ name: JobName.MEMORIES_CLEANUP, queue: QueueName.BACKGROUND_TASK })
 | 
				
			||||||
 | 
				
			|||||||
@ -15,6 +15,7 @@ describe(MemoryService.name, () => {
 | 
				
			|||||||
      database: db || defaultDatabase,
 | 
					      database: db || defaultDatabase,
 | 
				
			||||||
      repos: {
 | 
					      repos: {
 | 
				
			||||||
        asset: 'real',
 | 
					        asset: 'real',
 | 
				
			||||||
 | 
					        database: 'real',
 | 
				
			||||||
        memory: 'real',
 | 
					        memory: 'real',
 | 
				
			||||||
        user: 'real',
 | 
					        user: 'real',
 | 
				
			||||||
        systemMetadata: 'real',
 | 
					        systemMetadata: 'real',
 | 
				
			||||||
 | 
				
			|||||||
@ -1,37 +0,0 @@
 | 
				
			|||||||
<script lang="ts">
 | 
					 | 
				
			||||||
  interface Props {
 | 
					 | 
				
			||||||
    id: string;
 | 
					 | 
				
			||||||
    label: string;
 | 
					 | 
				
			||||||
    checked?: boolean | undefined;
 | 
					 | 
				
			||||||
    disabled?: boolean;
 | 
					 | 
				
			||||||
    labelClass?: string | undefined;
 | 
					 | 
				
			||||||
    name?: string | undefined;
 | 
					 | 
				
			||||||
    value?: string | undefined;
 | 
					 | 
				
			||||||
    onchange?: () => void;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  let {
 | 
					 | 
				
			||||||
    id,
 | 
					 | 
				
			||||||
    label,
 | 
					 | 
				
			||||||
    checked = $bindable(),
 | 
					 | 
				
			||||||
    disabled = false,
 | 
					 | 
				
			||||||
    labelClass = undefined,
 | 
					 | 
				
			||||||
    name = undefined,
 | 
					 | 
				
			||||||
    value = undefined,
 | 
					 | 
				
			||||||
    onchange = () => {},
 | 
					 | 
				
			||||||
  }: Props = $props();
 | 
					 | 
				
			||||||
</script>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
<div class="flex items-center space-x-2">
 | 
					 | 
				
			||||||
  <input
 | 
					 | 
				
			||||||
    type="checkbox"
 | 
					 | 
				
			||||||
    {name}
 | 
					 | 
				
			||||||
    {id}
 | 
					 | 
				
			||||||
    {value}
 | 
					 | 
				
			||||||
    {disabled}
 | 
					 | 
				
			||||||
    class="size-5 flex-shrink-0 focus-visible:ring"
 | 
					 | 
				
			||||||
    bind:checked
 | 
					 | 
				
			||||||
    {onchange}
 | 
					 | 
				
			||||||
  />
 | 
					 | 
				
			||||||
  <label class={labelClass} for={id}>{label}</label>
 | 
					 | 
				
			||||||
</div>
 | 
					 | 
				
			||||||
@ -1,94 +0,0 @@
 | 
				
			|||||||
<script lang="ts">
 | 
					 | 
				
			||||||
  interface Props {
 | 
					 | 
				
			||||||
    /**
 | 
					 | 
				
			||||||
     * Unique identifier for the checkbox element, used to associate labels with the input element.
 | 
					 | 
				
			||||||
     */
 | 
					 | 
				
			||||||
    id: string;
 | 
					 | 
				
			||||||
    /**
 | 
					 | 
				
			||||||
     * Optional aria-describedby attribute to associate the checkbox with a description.
 | 
					 | 
				
			||||||
     */
 | 
					 | 
				
			||||||
    ariaDescribedBy?: string | undefined;
 | 
					 | 
				
			||||||
    checked?: boolean;
 | 
					 | 
				
			||||||
    disabled?: boolean;
 | 
					 | 
				
			||||||
    onToggle?: ((checked: boolean) => void) | undefined;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  let {
 | 
					 | 
				
			||||||
    id,
 | 
					 | 
				
			||||||
    ariaDescribedBy = undefined,
 | 
					 | 
				
			||||||
    checked = $bindable(false),
 | 
					 | 
				
			||||||
    disabled = false,
 | 
					 | 
				
			||||||
    onToggle = undefined,
 | 
					 | 
				
			||||||
  }: Props = $props();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const handleToggle = (event: Event) => onToggle?.((event.target as HTMLInputElement).checked);
 | 
					 | 
				
			||||||
</script>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
<label class="relative inline-block h-[10px] w-[36px] flex-none">
 | 
					 | 
				
			||||||
  <input
 | 
					 | 
				
			||||||
    {id}
 | 
					 | 
				
			||||||
    class="disabled::cursor-not-allowed h-0 w-0 opacity-0 peer"
 | 
					 | 
				
			||||||
    type="checkbox"
 | 
					 | 
				
			||||||
    bind:checked
 | 
					 | 
				
			||||||
    onclick={handleToggle}
 | 
					 | 
				
			||||||
    {disabled}
 | 
					 | 
				
			||||||
    aria-describedby={ariaDescribedBy}
 | 
					 | 
				
			||||||
  />
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  {#if disabled}
 | 
					 | 
				
			||||||
    <span
 | 
					 | 
				
			||||||
      class="slider slider-disabled cursor-not-allowed border border-transparent before:border before:border-transparent"
 | 
					 | 
				
			||||||
    ></span>
 | 
					 | 
				
			||||||
  {:else}
 | 
					 | 
				
			||||||
    <span
 | 
					 | 
				
			||||||
      class="slider slider-enabled cursor-pointer border-2 border-transparent before:border-2 before:border-transparent peer-focus-visible:outline before:peer-focus-visible:outline peer-focus-visible:dark:outline-gray-200 before:peer-focus-visible:dark:outline-gray-200 peer-focus-visible:outline-gray-600 before:peer-focus-visible:outline-gray-600 peer-focus-visible:dark:border-black before:peer-focus-visible:dark:border-black peer-focus-visible:border-white before:peer-focus-visible:border-white"
 | 
					 | 
				
			||||||
    ></span>
 | 
					 | 
				
			||||||
  {/if}
 | 
					 | 
				
			||||||
</label>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
<style>
 | 
					 | 
				
			||||||
  .slider {
 | 
					 | 
				
			||||||
    position: absolute;
 | 
					 | 
				
			||||||
    top: 0;
 | 
					 | 
				
			||||||
    left: 0;
 | 
					 | 
				
			||||||
    right: 0;
 | 
					 | 
				
			||||||
    bottom: 0;
 | 
					 | 
				
			||||||
    background-color: #ccc;
 | 
					 | 
				
			||||||
    -webkit-transition: transform 0.4s;
 | 
					 | 
				
			||||||
    transition: transform 0.4s;
 | 
					 | 
				
			||||||
    border-radius: 34px;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  input:disabled {
 | 
					 | 
				
			||||||
    cursor: not-allowed;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  .slider:before {
 | 
					 | 
				
			||||||
    position: absolute;
 | 
					 | 
				
			||||||
    content: '';
 | 
					 | 
				
			||||||
    height: 20px;
 | 
					 | 
				
			||||||
    width: 20px;
 | 
					 | 
				
			||||||
    left: -2px;
 | 
					 | 
				
			||||||
    right: 0px;
 | 
					 | 
				
			||||||
    bottom: -6px;
 | 
					 | 
				
			||||||
    background-color: gray;
 | 
					 | 
				
			||||||
    -webkit-transition: transform 0.4s;
 | 
					 | 
				
			||||||
    transition: transform 0.4s;
 | 
					 | 
				
			||||||
    border-radius: 50%;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  input:checked + .slider:before {
 | 
					 | 
				
			||||||
    -webkit-transform: translateX(18px);
 | 
					 | 
				
			||||||
    -ms-transform: translateX(18px);
 | 
					 | 
				
			||||||
    transform: translateX(18px);
 | 
					 | 
				
			||||||
    background-color: #4250af;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  input:checked + .slider-disabled {
 | 
					 | 
				
			||||||
    background-color: gray;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  input:checked + .slider-enabled {
 | 
					 | 
				
			||||||
    background-color: #adcbfa;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
</style>
 | 
					 | 
				
			||||||
@ -8,6 +8,7 @@
 | 
				
			|||||||
  import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte';
 | 
					  import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte';
 | 
				
			||||||
  import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte';
 | 
					  import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte';
 | 
				
			||||||
  import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte';
 | 
					  import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte';
 | 
				
			||||||
 | 
					  import ChangeDescription from '$lib/components/photos-page/actions/change-description-action.svelte';
 | 
				
			||||||
  import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.svelte';
 | 
					  import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.svelte';
 | 
				
			||||||
  import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte';
 | 
					  import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte';
 | 
				
			||||||
  import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
 | 
					  import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
 | 
				
			||||||
@ -323,6 +324,7 @@
 | 
				
			|||||||
      <ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
 | 
					      <ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
 | 
				
			||||||
        <DownloadAction menuItem />
 | 
					        <DownloadAction menuItem />
 | 
				
			||||||
        <ChangeDate menuItem />
 | 
					        <ChangeDate menuItem />
 | 
				
			||||||
 | 
					        <ChangeDescription menuItem />
 | 
				
			||||||
        <ChangeLocation menuItem />
 | 
					        <ChangeLocation menuItem />
 | 
				
			||||||
        <ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} onArchive={handleDeleteOrArchiveAssets} />
 | 
					        <ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} onArchive={handleDeleteOrArchiveAssets} />
 | 
				
			||||||
        {#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
 | 
					        {#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
 | 
				
			||||||
 | 
				
			|||||||
@ -0,0 +1,37 @@
 | 
				
			|||||||
 | 
					<script lang="ts">
 | 
				
			||||||
 | 
					  import { modalManager } from '$lib/managers/modal-manager.svelte';
 | 
				
			||||||
 | 
					  import AssetUpdateDecriptionConfirmModal from '$lib/modals/AssetUpdateDecriptionConfirmModal.svelte';
 | 
				
			||||||
 | 
					  import { user } from '$lib/stores/user.store';
 | 
				
			||||||
 | 
					  import { getSelectedAssets } from '$lib/utils/asset-utils';
 | 
				
			||||||
 | 
					  import { handleError } from '$lib/utils/handle-error';
 | 
				
			||||||
 | 
					  import { updateAssets } from '@immich/sdk';
 | 
				
			||||||
 | 
					  import { mdiText } from '@mdi/js';
 | 
				
			||||||
 | 
					  import { t } from 'svelte-i18n';
 | 
				
			||||||
 | 
					  import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
 | 
				
			||||||
 | 
					  import { getAssetControlContext } from '../asset-select-control-bar.svelte';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  interface Props {
 | 
				
			||||||
 | 
					    menuItem?: boolean;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  let { menuItem = false }: Props = $props();
 | 
				
			||||||
 | 
					  const { clearSelect, getOwnedAssets } = getAssetControlContext();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const handleUpdateDescription = async () => {
 | 
				
			||||||
 | 
					    const description = await modalManager.show(AssetUpdateDecriptionConfirmModal, {});
 | 
				
			||||||
 | 
					    if (description) {
 | 
				
			||||||
 | 
					      const ids = getSelectedAssets(getOwnedAssets(), $user);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      try {
 | 
				
			||||||
 | 
					        await updateAssets({ assetBulkUpdateDto: { ids, description } });
 | 
				
			||||||
 | 
					      } catch (error) {
 | 
				
			||||||
 | 
					        handleError(error, $t('errors.unable_to_change_description'));
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      clearSelect();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{#if menuItem}
 | 
				
			||||||
 | 
					  <MenuOption text={$t('change_description')} icon={mdiText} onClick={() => handleUpdateDescription()} />
 | 
				
			||||||
 | 
					{/if}
 | 
				
			||||||
@ -1,8 +1,8 @@
 | 
				
			|||||||
<script lang="ts">
 | 
					<script lang="ts">
 | 
				
			||||||
  import Checkbox from '$lib/components/elements/checkbox.svelte';
 | 
					 | 
				
			||||||
  import FormatMessage from '$lib/components/i18n/format-message.svelte';
 | 
					  import FormatMessage from '$lib/components/i18n/format-message.svelte';
 | 
				
			||||||
  import ConfirmModal from '$lib/modals/ConfirmModal.svelte';
 | 
					  import ConfirmModal from '$lib/modals/ConfirmModal.svelte';
 | 
				
			||||||
  import { showDeleteModal } from '$lib/stores/preferences.store';
 | 
					  import { showDeleteModal } from '$lib/stores/preferences.store';
 | 
				
			||||||
 | 
					  import { Checkbox, Label } from '@immich/ui';
 | 
				
			||||||
  import { t } from 'svelte-i18n';
 | 
					  import { t } from 'svelte-i18n';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  interface Props {
 | 
					  interface Props {
 | 
				
			||||||
@ -38,8 +38,9 @@
 | 
				
			|||||||
    </p>
 | 
					    </p>
 | 
				
			||||||
    <p><b>{$t('cannot_undo_this_action')}</b></p>
 | 
					    <p><b>{$t('cannot_undo_this_action')}</b></p>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <div class="pt-4 flex justify-center items-center">
 | 
					    <div class="pt-4 flex justify-center items-center gap-2">
 | 
				
			||||||
      <Checkbox id="confirm-deletion-input" label={$t('do_not_show_again')} bind:checked />
 | 
					      <Checkbox id="confirm-deletion-input" bind:checked color="secondary" />
 | 
				
			||||||
 | 
					      <Label label={$t('do_not_show_again')} for="confirm-deletion-input" />
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
  {/snippet}
 | 
					  {/snippet}
 | 
				
			||||||
</ConfirmModal>
 | 
					</ConfirmModal>
 | 
				
			||||||
 | 
				
			|||||||
@ -1,6 +1,4 @@
 | 
				
			|||||||
<script lang="ts">
 | 
					<script lang="ts">
 | 
				
			||||||
  import { Theme } from '$lib/constants';
 | 
					 | 
				
			||||||
  import { themeManager } from '$lib/managers/theme-manager.svelte';
 | 
					 | 
				
			||||||
  import QRCode from 'qrcode';
 | 
					  import QRCode from 'qrcode';
 | 
				
			||||||
  import { t } from 'svelte-i18n';
 | 
					  import { t } from 'svelte-i18n';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -12,13 +10,7 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  const { value, width, alt = $t('alt_text_qr_code') }: Props = $props();
 | 
					  const { value, width, alt = $t('alt_text_qr_code') }: Props = $props();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  let promise = $derived(
 | 
					  let promise = $derived(QRCode.toDataURL(value, { margin: 0, width }));
 | 
				
			||||||
    QRCode.toDataURL(value, {
 | 
					 | 
				
			||||||
      color: { dark: themeManager.value === Theme.DARK ? '#ffffffff' : '#000000ff', light: '#00000000' },
 | 
					 | 
				
			||||||
      margin: 0,
 | 
					 | 
				
			||||||
      width,
 | 
					 | 
				
			||||||
    }),
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<div style="width: {width}px; height: {width}px">
 | 
					<div style="width: {width}px; height: {width}px">
 | 
				
			||||||
 | 
				
			|||||||
@ -1,13 +1,14 @@
 | 
				
			|||||||
<script lang="ts" module>
 | 
					<script lang="ts" module>
 | 
				
			||||||
  export interface SearchDisplayFilters {
 | 
					  export interface SearchDisplayFilters {
 | 
				
			||||||
    isNotInAlbum?: boolean;
 | 
					    isNotInAlbum: boolean;
 | 
				
			||||||
    isArchive?: boolean;
 | 
					    isArchive: boolean;
 | 
				
			||||||
    isFavorite?: boolean;
 | 
					    isFavorite: boolean;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<script lang="ts">
 | 
					<script lang="ts">
 | 
				
			||||||
  import Checkbox from '$lib/components/elements/checkbox.svelte';
 | 
					  import { Checkbox, Label } from '@immich/ui';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  import { t } from 'svelte-i18n';
 | 
					  import { t } from 'svelte-i18n';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  interface Props {
 | 
					  interface Props {
 | 
				
			||||||
@ -21,9 +22,18 @@
 | 
				
			|||||||
  <fieldset>
 | 
					  <fieldset>
 | 
				
			||||||
    <legend class="immich-form-label">{$t('display_options').toUpperCase()}</legend>
 | 
					    <legend class="immich-form-label">{$t('display_options').toUpperCase()}</legend>
 | 
				
			||||||
    <div class="flex flex-wrap gap-x-5 gap-y-2 mt-1">
 | 
					    <div class="flex flex-wrap gap-x-5 gap-y-2 mt-1">
 | 
				
			||||||
      <Checkbox id="not-in-album-checkbox" label={$t('not_in_any_album')} bind:checked={filters.isNotInAlbum} />
 | 
					      <div class="flex items-center gap-2">
 | 
				
			||||||
      <Checkbox id="archive-checkbox" label={$t('archive')} bind:checked={filters.isArchive} />
 | 
					        <Checkbox id="not-in-album-checkbox" size="tiny" bind:checked={filters.isNotInAlbum} />
 | 
				
			||||||
      <Checkbox id="favorite-checkbox" label={$t('favorites')} bind:checked={filters.isFavorite} />
 | 
					        <Label label={$t('not_in_any_album')} for="not-in-album-checkbox" />
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					      <div class="flex items-center gap-2">
 | 
				
			||||||
 | 
					        <Checkbox id="archive-checkbox" size="tiny" bind:checked={filters.isArchive} />
 | 
				
			||||||
 | 
					        <Label label={$t('archive')} for="archive-checkbox" />
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					      <div class="flex items-center gap-2">
 | 
				
			||||||
 | 
					        <Checkbox id="favorites-checkbox" size="tiny" bind:checked={filters.isFavorite} />
 | 
				
			||||||
 | 
					        <Label label={$t('favorites')} for="favorites-checkbox" />
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
  </fieldset>
 | 
					  </fieldset>
 | 
				
			||||||
</div>
 | 
					</div>
 | 
				
			||||||
 | 
				
			|||||||
@ -1,8 +1,8 @@
 | 
				
			|||||||
<script lang="ts">
 | 
					<script lang="ts">
 | 
				
			||||||
  import Checkbox from '$lib/components/elements/checkbox.svelte';
 | 
					  import { Checkbox, Label } from '@immich/ui';
 | 
				
			||||||
 | 
					  import { t } from 'svelte-i18n';
 | 
				
			||||||
  import { quintOut } from 'svelte/easing';
 | 
					  import { quintOut } from 'svelte/easing';
 | 
				
			||||||
  import { fly } from 'svelte/transition';
 | 
					  import { fly } from 'svelte/transition';
 | 
				
			||||||
  import { t } from 'svelte-i18n';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  interface Props {
 | 
					  interface Props {
 | 
				
			||||||
    value: string[];
 | 
					    value: string[];
 | 
				
			||||||
@ -52,14 +52,18 @@
 | 
				
			|||||||
  {/if}
 | 
					  {/if}
 | 
				
			||||||
  <div class="flex flex-col gap-2">
 | 
					  <div class="flex flex-col gap-2">
 | 
				
			||||||
    {#each options as option (option.value)}
 | 
					    {#each options as option (option.value)}
 | 
				
			||||||
      <Checkbox
 | 
					      <div class="flex gap-2 items-center">
 | 
				
			||||||
        id="{option.value}-checkbox"
 | 
					        <Checkbox
 | 
				
			||||||
        label={option.text}
 | 
					          size="tiny"
 | 
				
			||||||
        checked={value.includes(option.value)}
 | 
					          id="{option.value}-checkbox"
 | 
				
			||||||
        {disabled}
 | 
					          checked={value.includes(option.value)}
 | 
				
			||||||
        labelClass="text-gray-500 dark:text-gray-300"
 | 
					          {disabled}
 | 
				
			||||||
        onchange={() => handleCheckboxChange(option.value)}
 | 
					          onCheckedChange={() => handleCheckboxChange(option.value)}
 | 
				
			||||||
      />
 | 
					        />
 | 
				
			||||||
 | 
					        <Label label={option.text} for="{option.value}-checkbox">
 | 
				
			||||||
 | 
					          {option.text}
 | 
				
			||||||
 | 
					        </Label>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
    {/each}
 | 
					    {/each}
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
</div>
 | 
					</div>
 | 
				
			||||||
 | 
				
			|||||||
@ -1,10 +1,10 @@
 | 
				
			|||||||
<script lang="ts">
 | 
					<script lang="ts">
 | 
				
			||||||
 | 
					  import { generateId } from '$lib/utils/generate-id';
 | 
				
			||||||
 | 
					  import { Switch } from '@immich/ui';
 | 
				
			||||||
 | 
					  import type { Snippet } from 'svelte';
 | 
				
			||||||
 | 
					  import { t } from 'svelte-i18n';
 | 
				
			||||||
  import { quintOut } from 'svelte/easing';
 | 
					  import { quintOut } from 'svelte/easing';
 | 
				
			||||||
  import { fly } from 'svelte/transition';
 | 
					  import { fly } from 'svelte/transition';
 | 
				
			||||||
  import Slider from '$lib/components/elements/slider.svelte';
 | 
					 | 
				
			||||||
  import { generateId } from '$lib/utils/generate-id';
 | 
					 | 
				
			||||||
  import { t } from 'svelte-i18n';
 | 
					 | 
				
			||||||
  import type { Snippet } from 'svelte';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  interface Props {
 | 
					  interface Props {
 | 
				
			||||||
    title: string;
 | 
					    title: string;
 | 
				
			||||||
@ -54,5 +54,5 @@
 | 
				
			|||||||
    {@render children?.()}
 | 
					    {@render children?.()}
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  <Slider id={sliderId} bind:checked {disabled} {onToggle} ariaDescribedBy={subtitleId} />
 | 
					  <Switch id={sliderId} bind:checked {disabled} onCheckedChange={onToggle} aria-describedby={subtitleId} />
 | 
				
			||||||
</div>
 | 
					</div>
 | 
				
			||||||
 | 
				
			|||||||
@ -25,6 +25,13 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  let { onClose = () => {} }: Props = $props();
 | 
					  let { onClose = () => {} }: Props = $props();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Temporary variables to hold the settings - marked as reactive with $state() but initialized with store values
 | 
				
			||||||
 | 
					  let tempSlideshowDelay = $state($slideshowDelay);
 | 
				
			||||||
 | 
					  let tempShowProgressBar = $state($showProgressBar);
 | 
				
			||||||
 | 
					  let tempSlideshowNavigation = $state($slideshowNavigation);
 | 
				
			||||||
 | 
					  let tempSlideshowLook = $state($slideshowLook);
 | 
				
			||||||
 | 
					  let tempSlideshowTransition = $state($slideshowTransition);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const navigationOptions: Record<SlideshowNavigation, RenderedOption> = {
 | 
					  const navigationOptions: Record<SlideshowNavigation, RenderedOption> = {
 | 
				
			||||||
    [SlideshowNavigation.Shuffle]: { icon: mdiShuffle, title: $t('shuffle') },
 | 
					    [SlideshowNavigation.Shuffle]: { icon: mdiShuffle, title: $t('shuffle') },
 | 
				
			||||||
    [SlideshowNavigation.AscendingOrder]: { icon: mdiArrowUpThin, title: $t('backward') },
 | 
					    [SlideshowNavigation.AscendingOrder]: { icon: mdiArrowUpThin, title: $t('backward') },
 | 
				
			||||||
@ -47,6 +54,15 @@
 | 
				
			|||||||
      }
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const applyChanges = () => {
 | 
				
			||||||
 | 
					    $slideshowDelay = tempSlideshowDelay;
 | 
				
			||||||
 | 
					    $showProgressBar = tempShowProgressBar;
 | 
				
			||||||
 | 
					    $slideshowNavigation = tempSlideshowNavigation;
 | 
				
			||||||
 | 
					    $slideshowLook = tempSlideshowLook;
 | 
				
			||||||
 | 
					    $slideshowTransition = tempSlideshowTransition;
 | 
				
			||||||
 | 
					    onClose();
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<FullScreenModal title={$t('slideshow_settings')} onClose={() => onClose()}>
 | 
					<FullScreenModal title={$t('slideshow_settings')} onClose={() => onClose()}>
 | 
				
			||||||
@ -54,31 +70,32 @@
 | 
				
			|||||||
    <SettingDropdown
 | 
					    <SettingDropdown
 | 
				
			||||||
      title={$t('direction')}
 | 
					      title={$t('direction')}
 | 
				
			||||||
      options={Object.values(navigationOptions)}
 | 
					      options={Object.values(navigationOptions)}
 | 
				
			||||||
      selectedOption={navigationOptions[$slideshowNavigation]}
 | 
					      selectedOption={navigationOptions[tempSlideshowNavigation]}
 | 
				
			||||||
      onToggle={(option) => {
 | 
					      onToggle={(option) => {
 | 
				
			||||||
        $slideshowNavigation = handleToggle(option, navigationOptions) || $slideshowNavigation;
 | 
					        tempSlideshowNavigation = handleToggle(option, navigationOptions) || tempSlideshowNavigation;
 | 
				
			||||||
      }}
 | 
					      }}
 | 
				
			||||||
    />
 | 
					    />
 | 
				
			||||||
    <SettingDropdown
 | 
					    <SettingDropdown
 | 
				
			||||||
      title={$t('look')}
 | 
					      title={$t('look')}
 | 
				
			||||||
      options={Object.values(lookOptions)}
 | 
					      options={Object.values(lookOptions)}
 | 
				
			||||||
      selectedOption={lookOptions[$slideshowLook]}
 | 
					      selectedOption={lookOptions[tempSlideshowLook]}
 | 
				
			||||||
      onToggle={(option) => {
 | 
					      onToggle={(option) => {
 | 
				
			||||||
        $slideshowLook = handleToggle(option, lookOptions) || $slideshowLook;
 | 
					        tempSlideshowLook = handleToggle(option, lookOptions) || tempSlideshowLook;
 | 
				
			||||||
      }}
 | 
					      }}
 | 
				
			||||||
    />
 | 
					    />
 | 
				
			||||||
    <SettingSwitch title={$t('show_progress_bar')} bind:checked={$showProgressBar} />
 | 
					    <SettingSwitch title={$t('show_progress_bar')} bind:checked={tempShowProgressBar} />
 | 
				
			||||||
    <SettingSwitch title={$t('show_slideshow_transition')} bind:checked={$slideshowTransition} />
 | 
					    <SettingSwitch title={$t('show_slideshow_transition')} bind:checked={tempSlideshowTransition} />
 | 
				
			||||||
    <SettingInputField
 | 
					    <SettingInputField
 | 
				
			||||||
      inputType={SettingInputFieldType.NUMBER}
 | 
					      inputType={SettingInputFieldType.NUMBER}
 | 
				
			||||||
      label={$t('duration')}
 | 
					      label={$t('duration')}
 | 
				
			||||||
      description={$t('admin.slideshow_duration_description')}
 | 
					      description={$t('admin.slideshow_duration_description')}
 | 
				
			||||||
      min={1}
 | 
					      min={1}
 | 
				
			||||||
      bind:value={$slideshowDelay}
 | 
					      bind:value={tempSlideshowDelay}
 | 
				
			||||||
    />
 | 
					    />
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  {#snippet stickyBottom()}
 | 
					  {#snippet stickyBottom()}
 | 
				
			||||||
    <Button fullWidth shape="round" color="primary" onclick={() => onClose()}>{$t('done')}</Button>
 | 
					    <Button color="secondary" shape="round" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
 | 
				
			||||||
 | 
					    <Button fullWidth color="primary" shape="round" onclick={applyChanges}>{$t('confirm')}</Button>
 | 
				
			||||||
  {/snippet}
 | 
					  {/snippet}
 | 
				
			||||||
</FullScreenModal>
 | 
					</FullScreenModal>
 | 
				
			||||||
 | 
				
			|||||||
@ -18,6 +18,7 @@
 | 
				
			|||||||
  import { fade } from 'svelte/transition';
 | 
					  import { fade } from 'svelte/transition';
 | 
				
			||||||
  import { handleError } from '../../utils/handle-error';
 | 
					  import { handleError } from '../../utils/handle-error';
 | 
				
			||||||
  import { notificationController, NotificationType } from '../shared-components/notification/notification';
 | 
					  import { notificationController, NotificationType } from '../shared-components/notification/notification';
 | 
				
			||||||
 | 
					  import { dateFormats } from '$lib/constants';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  interface Props {
 | 
					  interface Props {
 | 
				
			||||||
    keys: ApiKeyResponseDto[];
 | 
					    keys: ApiKeyResponseDto[];
 | 
				
			||||||
@ -25,12 +26,6 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  let { keys = $bindable() }: Props = $props();
 | 
					  let { keys = $bindable() }: Props = $props();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const format: Intl.DateTimeFormatOptions = {
 | 
					 | 
				
			||||||
    month: 'short',
 | 
					 | 
				
			||||||
    day: 'numeric',
 | 
					 | 
				
			||||||
    year: 'numeric',
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  async function refreshKeys() {
 | 
					  async function refreshKeys() {
 | 
				
			||||||
    keys = await getApiKeys();
 | 
					    keys = await getApiKeys();
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
@ -130,7 +125,7 @@
 | 
				
			|||||||
            >
 | 
					            >
 | 
				
			||||||
              <td class="w-1/3 text-ellipsis px-4 text-sm">{key.name}</td>
 | 
					              <td class="w-1/3 text-ellipsis px-4 text-sm">{key.name}</td>
 | 
				
			||||||
              <td class="w-1/3 text-ellipsis px-4 text-sm"
 | 
					              <td class="w-1/3 text-ellipsis px-4 text-sm"
 | 
				
			||||||
                >{new Date(key.createdAt).toLocaleDateString($locale, format)}
 | 
					                >{new Date(key.createdAt).toLocaleDateString($locale, dateFormats.settings)}
 | 
				
			||||||
              </td>
 | 
					              </td>
 | 
				
			||||||
              <td class="flex flex-row flex-wrap justify-center gap-x-2 gap-y-1 w-1/3">
 | 
					              <td class="flex flex-row flex-wrap justify-center gap-x-2 gap-y-1 w-1/3">
 | 
				
			||||||
                <CircleIconButton
 | 
					                <CircleIconButton
 | 
				
			||||||
 | 
				
			|||||||
@ -4,7 +4,9 @@
 | 
				
			|||||||
  import Icon from '$lib/components/elements/icon.svelte';
 | 
					  import Icon from '$lib/components/elements/icon.svelte';
 | 
				
			||||||
  import PurchaseContent from '$lib/components/shared-components/purchasing/purchase-content.svelte';
 | 
					  import PurchaseContent from '$lib/components/shared-components/purchasing/purchase-content.svelte';
 | 
				
			||||||
  import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
 | 
					  import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
 | 
				
			||||||
 | 
					  import { dateFormats } from '$lib/constants';
 | 
				
			||||||
  import { modalManager } from '$lib/managers/modal-manager.svelte';
 | 
					  import { modalManager } from '$lib/managers/modal-manager.svelte';
 | 
				
			||||||
 | 
					  import { locale } from '$lib/stores/preferences.store';
 | 
				
			||||||
  import { purchaseStore } from '$lib/stores/purchase.store';
 | 
					  import { purchaseStore } from '$lib/stores/purchase.store';
 | 
				
			||||||
  import { preferences, user } from '$lib/stores/user.store';
 | 
					  import { preferences, user } from '$lib/stores/user.store';
 | 
				
			||||||
  import { handleError } from '$lib/utils/handle-error';
 | 
					  import { handleError } from '$lib/utils/handle-error';
 | 
				
			||||||
@ -132,7 +134,9 @@
 | 
				
			|||||||
            {#if $user.isAdmin && serverPurchaseInfo?.activatedAt}
 | 
					            {#if $user.isAdmin && serverPurchaseInfo?.activatedAt}
 | 
				
			||||||
              <p class="dark:text-white text-sm mt-1 col-start-2">
 | 
					              <p class="dark:text-white text-sm mt-1 col-start-2">
 | 
				
			||||||
                {$t('purchase_activated_time', {
 | 
					                {$t('purchase_activated_time', {
 | 
				
			||||||
                  values: { date: new Date(serverPurchaseInfo.activatedAt) },
 | 
					                  values: {
 | 
				
			||||||
 | 
					                    date: new Date(serverPurchaseInfo.activatedAt).toLocaleString($locale, dateFormats.settings),
 | 
				
			||||||
 | 
					                  },
 | 
				
			||||||
                })}
 | 
					                })}
 | 
				
			||||||
              </p>
 | 
					              </p>
 | 
				
			||||||
            {:else}
 | 
					            {:else}
 | 
				
			||||||
@ -161,7 +165,9 @@
 | 
				
			|||||||
            {#if $user.license?.activatedAt}
 | 
					            {#if $user.license?.activatedAt}
 | 
				
			||||||
              <p class="dark:text-white text-sm mt-1 col-start-2">
 | 
					              <p class="dark:text-white text-sm mt-1 col-start-2">
 | 
				
			||||||
                {$t('purchase_activated_time', {
 | 
					                {$t('purchase_activated_time', {
 | 
				
			||||||
                  values: { date: new Date($user.license?.activatedAt) },
 | 
					                  values: {
 | 
				
			||||||
 | 
					                    date: new Date($user.license?.activatedAt).toLocaleString($locale, dateFormats.settings),
 | 
				
			||||||
 | 
					                  },
 | 
				
			||||||
                })}
 | 
					                })}
 | 
				
			||||||
              </p>
 | 
					              </p>
 | 
				
			||||||
            {/if}
 | 
					            {/if}
 | 
				
			||||||
 | 
				
			|||||||
@ -72,6 +72,11 @@ export const dateFormats = {
 | 
				
			|||||||
    day: 'numeric',
 | 
					    day: 'numeric',
 | 
				
			||||||
    year: 'numeric',
 | 
					    year: 'numeric',
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
 | 
					  settings: <Intl.DateTimeFormatOptions>{
 | 
				
			||||||
 | 
					    month: 'short',
 | 
				
			||||||
 | 
					    day: 'numeric',
 | 
				
			||||||
 | 
					    year: 'numeric',
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export enum QueryParameter {
 | 
					export enum QueryParameter {
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										29
									
								
								web/src/lib/modals/AssetUpdateDecriptionConfirmModal.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								web/src/lib/modals/AssetUpdateDecriptionConfirmModal.svelte
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,29 @@
 | 
				
			|||||||
 | 
					<script lang="ts">
 | 
				
			||||||
 | 
					  import ConfirmModal from '$lib/modals/ConfirmModal.svelte';
 | 
				
			||||||
 | 
					  import { Input } from '@immich/ui';
 | 
				
			||||||
 | 
					  import { t } from 'svelte-i18n';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  interface Props {
 | 
				
			||||||
 | 
					    onClose: (description?: string) => void;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  let { onClose }: Props = $props();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  let description = $state('');
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<ConfirmModal
 | 
				
			||||||
 | 
					  confirmColor="primary"
 | 
				
			||||||
 | 
					  title={$t('edit_description')}
 | 
				
			||||||
 | 
					  prompt={$t('edit_description_prompt')}
 | 
				
			||||||
 | 
					  onClose={(confirmed) => (confirmed ? onClose(description) : onClose())}
 | 
				
			||||||
 | 
					>
 | 
				
			||||||
 | 
					  {#snippet promptSnippet()}
 | 
				
			||||||
 | 
					    <div class="flex flex-col text-start gap-2">
 | 
				
			||||||
 | 
					      <div class="flex flex-col">
 | 
				
			||||||
 | 
					        <label for="description">{$t('description')}</label>
 | 
				
			||||||
 | 
					        <Input class="immich-form-input" id="description" bind:value={description} />
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  {/snippet}
 | 
				
			||||||
 | 
					</ConfirmModal>
 | 
				
			||||||
@ -30,7 +30,7 @@
 | 
				
			|||||||
  };
 | 
					  };
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<Modal {title} onClose={() => onClose(false)} {size} class="bg-light text-dark">
 | 
					<Modal {title} onClose={() => onClose(false)} {size}>
 | 
				
			||||||
  <ModalBody>
 | 
					  <ModalBody>
 | 
				
			||||||
    {#if promptSnippet}{@render promptSnippet()}{:else}
 | 
					    {#if promptSnippet}{@render promptSnippet()}{:else}
 | 
				
			||||||
      <p>{prompt}</p>
 | 
					      <p>{prompt}</p>
 | 
				
			||||||
 | 
				
			|||||||
@ -12,13 +12,7 @@
 | 
				
			|||||||
  const { onClose, newPassword }: Props = $props();
 | 
					  const { onClose, newPassword }: Props = $props();
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<Modal
 | 
					<Modal title={$t('password_reset_success')} icon={mdiCheck} onClose={() => onClose()} size="small">
 | 
				
			||||||
  title={$t('password_reset_success')}
 | 
					 | 
				
			||||||
  icon={mdiCheck}
 | 
					 | 
				
			||||||
  onClose={() => onClose()}
 | 
					 | 
				
			||||||
  size="small"
 | 
					 | 
				
			||||||
  class="bg-light text-dark"
 | 
					 | 
				
			||||||
>
 | 
					 | 
				
			||||||
  <ModalBody>
 | 
					  <ModalBody>
 | 
				
			||||||
    <div class="flex flex-col gap-4">
 | 
					    <div class="flex flex-col gap-4">
 | 
				
			||||||
      <Text>{$t('admin.user_password_has_been_reset')}</Text>
 | 
					      <Text>{$t('admin.user_password_has_been_reset')}</Text>
 | 
				
			||||||
 | 
				
			|||||||
@ -24,7 +24,7 @@
 | 
				
			|||||||
    try {
 | 
					    try {
 | 
				
			||||||
      const updatedPerson = await updatePerson({
 | 
					      const updatedPerson = await updatePerson({
 | 
				
			||||||
        id: person.id,
 | 
					        id: person.id,
 | 
				
			||||||
        personUpdateDto: { birthDate: birthDate.length > 0 ? birthDate : null },
 | 
					        personUpdateDto: { birthDate },
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      notificationController.show({ message: $t('date_of_birth_saved'), type: NotificationType.Info });
 | 
					      notificationController.show({ message: $t('date_of_birth_saved'), type: NotificationType.Info });
 | 
				
			||||||
@ -53,6 +53,13 @@
 | 
				
			|||||||
          bind:value={birthDate}
 | 
					          bind:value={birthDate}
 | 
				
			||||||
          max={todayFormatted}
 | 
					          max={todayFormatted}
 | 
				
			||||||
        />
 | 
					        />
 | 
				
			||||||
 | 
					        {#if person.birthDate}
 | 
				
			||||||
 | 
					          <div class="flex justify-end">
 | 
				
			||||||
 | 
					            <Button shape="round" color="secondary" size="small" onclick={() => (birthDate = '')}>
 | 
				
			||||||
 | 
					              {$t('clear')}
 | 
				
			||||||
 | 
					            </Button>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        {/if}
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    </form>
 | 
					    </form>
 | 
				
			||||||
  </ModalBody>
 | 
					  </ModalBody>
 | 
				
			||||||
@ -62,8 +69,8 @@
 | 
				
			|||||||
      <Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>
 | 
					      <Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>
 | 
				
			||||||
        {$t('cancel')}
 | 
					        {$t('cancel')}
 | 
				
			||||||
      </Button>
 | 
					      </Button>
 | 
				
			||||||
      <Button type="submit" shape="round" color="primary" fullWidth>
 | 
					      <Button type="submit" shape="round" color="primary" fullWidth form="set-birth-date-form">
 | 
				
			||||||
        {$t('set')}
 | 
					        {$t('save')}
 | 
				
			||||||
      </Button>
 | 
					      </Button>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
  </ModalFooter>
 | 
					  </ModalFooter>
 | 
				
			||||||
 | 
				
			|||||||
@ -84,8 +84,8 @@
 | 
				
			|||||||
    },
 | 
					    },
 | 
				
			||||||
    display: {
 | 
					    display: {
 | 
				
			||||||
      isArchive: searchQuery.visibility === AssetVisibility.Archive,
 | 
					      isArchive: searchQuery.visibility === AssetVisibility.Archive,
 | 
				
			||||||
      isFavorite: searchQuery.isFavorite,
 | 
					      isFavorite: searchQuery.isFavorite ?? false,
 | 
				
			||||||
      isNotInAlbum: 'isNotInAlbum' in searchQuery ? searchQuery.isNotInAlbum : undefined,
 | 
					      isNotInAlbum: 'isNotInAlbum' in searchQuery ? (searchQuery.isNotInAlbum ?? false) : false,
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    mediaType:
 | 
					    mediaType:
 | 
				
			||||||
      searchQuery.type === AssetTypeEnum.Image
 | 
					      searchQuery.type === AssetTypeEnum.Image
 | 
				
			||||||
@ -105,7 +105,11 @@
 | 
				
			|||||||
      location: {},
 | 
					      location: {},
 | 
				
			||||||
      camera: {},
 | 
					      camera: {},
 | 
				
			||||||
      date: {},
 | 
					      date: {},
 | 
				
			||||||
      display: {},
 | 
					      display: {
 | 
				
			||||||
 | 
					        isArchive: false,
 | 
				
			||||||
 | 
					        isFavorite: false,
 | 
				
			||||||
 | 
					        isNotInAlbum: false,
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
      mediaType: MediaType.All,
 | 
					      mediaType: MediaType.All,
 | 
				
			||||||
      rating: undefined,
 | 
					      rating: undefined,
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
				
			|||||||
@ -81,7 +81,7 @@
 | 
				
			|||||||
  };
 | 
					  };
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<Modal title={$t('create_new_user')} {onClose} size="small" class="text-dark bg-light">
 | 
					<Modal title={$t('create_new_user')} {onClose} size="small">
 | 
				
			||||||
  <ModalBody>
 | 
					  <ModalBody>
 | 
				
			||||||
    <form onsubmit={onSubmit} autocomplete="off" id="create-new-user-form">
 | 
					    <form onsubmit={onSubmit} autocomplete="off" id="create-new-user-form">
 | 
				
			||||||
      {#if error}
 | 
					      {#if error}
 | 
				
			||||||
 | 
				
			|||||||
@ -1,10 +1,10 @@
 | 
				
			|||||||
<script lang="ts">
 | 
					<script lang="ts">
 | 
				
			||||||
  import Checkbox from '$lib/components/elements/checkbox.svelte';
 | 
					 | 
				
			||||||
  import FormatMessage from '$lib/components/i18n/format-message.svelte';
 | 
					  import FormatMessage from '$lib/components/i18n/format-message.svelte';
 | 
				
			||||||
  import ConfirmModal from '$lib/modals/ConfirmModal.svelte';
 | 
					  import ConfirmModal from '$lib/modals/ConfirmModal.svelte';
 | 
				
			||||||
  import { serverConfig } from '$lib/stores/server-config.store';
 | 
					  import { serverConfig } from '$lib/stores/server-config.store';
 | 
				
			||||||
  import { handleError } from '$lib/utils/handle-error';
 | 
					  import { handleError } from '$lib/utils/handle-error';
 | 
				
			||||||
  import { deleteUserAdmin, type UserAdminResponseDto, type UserResponseDto } from '@immich/sdk';
 | 
					  import { deleteUserAdmin, type UserAdminResponseDto, type UserResponseDto } from '@immich/sdk';
 | 
				
			||||||
 | 
					  import { Checkbox, Label } from '@immich/ui';
 | 
				
			||||||
  import { t } from 'svelte-i18n';
 | 
					  import { t } from 'svelte-i18n';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  interface Props {
 | 
					  interface Props {
 | 
				
			||||||
@ -66,16 +66,14 @@
 | 
				
			|||||||
        </p>
 | 
					        </p>
 | 
				
			||||||
      {/if}
 | 
					      {/if}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <div class="flex justify-center m-4 gap-2">
 | 
					      <div class="flex justify-center items-center gap-2">
 | 
				
			||||||
        <Checkbox
 | 
					        <Checkbox
 | 
				
			||||||
          id="queue-user-deletion-checkbox"
 | 
					          id="queue-user-deletion-checkbox"
 | 
				
			||||||
          label={$t('admin.user_delete_immediately_checkbox')}
 | 
					          color="secondary"
 | 
				
			||||||
          labelClass="text-sm dark:text-immich-dark-fg"
 | 
					 | 
				
			||||||
          bind:checked={forceDelete}
 | 
					          bind:checked={forceDelete}
 | 
				
			||||||
          onchange={() => {
 | 
					          onCheckedChange={() => (deleteButtonDisabled = forceDelete)}
 | 
				
			||||||
            deleteButtonDisabled = forceDelete;
 | 
					 | 
				
			||||||
          }}
 | 
					 | 
				
			||||||
        />
 | 
					        />
 | 
				
			||||||
 | 
					        <Label label={$t('admin.user_delete_immediately_checkbox')} for="queue-user-deletion-checkbox" />
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      {#if forceDelete}
 | 
					      {#if forceDelete}
 | 
				
			||||||
 | 
				
			|||||||
@ -51,7 +51,7 @@
 | 
				
			|||||||
  };
 | 
					  };
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<Modal title={$t('edit_user')} size="small" icon={mdiAccountEditOutline} {onClose} class="text-dark bg-light">
 | 
					<Modal title={$t('edit_user')} size="small" icon={mdiAccountEditOutline} {onClose}>
 | 
				
			||||||
  <ModalBody>
 | 
					  <ModalBody>
 | 
				
			||||||
    <form onsubmit={onSubmit} autocomplete="off" id="edit-user-form">
 | 
					    <form onsubmit={onSubmit} autocomplete="off" id="edit-user-form">
 | 
				
			||||||
      <div class="mb-4 flex flex-col gap-2">
 | 
					      <div class="mb-4 flex flex-col gap-2">
 | 
				
			||||||
 | 
				
			|||||||
@ -23,7 +23,7 @@
 | 
				
			|||||||
  };
 | 
					  };
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<Modal title={$t('restore_user')} {onClose} icon={mdiDeleteRestore} size="small" class="bg-light text-dark">
 | 
					<Modal title={$t('restore_user')} {onClose} icon={mdiDeleteRestore} size="small">
 | 
				
			||||||
  <ModalBody>
 | 
					  <ModalBody>
 | 
				
			||||||
    <p>
 | 
					    <p>
 | 
				
			||||||
      <FormatMessage key="admin.user_restore_description" values={{ user: user.name }}>
 | 
					      <FormatMessage key="admin.user_restore_description" values={{ user: user.name }}>
 | 
				
			||||||
 | 
				
			|||||||
@ -13,6 +13,7 @@
 | 
				
			|||||||
  import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte';
 | 
					  import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte';
 | 
				
			||||||
  import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte';
 | 
					  import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte';
 | 
				
			||||||
  import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte';
 | 
					  import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte';
 | 
				
			||||||
 | 
					  import ChangeDescription from '$lib/components/photos-page/actions/change-description-action.svelte';
 | 
				
			||||||
  import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.svelte';
 | 
					  import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.svelte';
 | 
				
			||||||
  import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte';
 | 
					  import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte';
 | 
				
			||||||
  import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
 | 
					  import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
 | 
				
			||||||
@ -478,6 +479,7 @@
 | 
				
			|||||||
          <DownloadAction menuItem filename="{album.albumName}.zip" />
 | 
					          <DownloadAction menuItem filename="{album.albumName}.zip" />
 | 
				
			||||||
          {#if assetInteraction.isAllUserOwned}
 | 
					          {#if assetInteraction.isAllUserOwned}
 | 
				
			||||||
            <ChangeDate menuItem />
 | 
					            <ChangeDate menuItem />
 | 
				
			||||||
 | 
					            <ChangeDescription menuItem />
 | 
				
			||||||
            <ChangeLocation menuItem />
 | 
					            <ChangeLocation menuItem />
 | 
				
			||||||
            {#if assetInteraction.selectedAssets.length === 1}
 | 
					            {#if assetInteraction.selectedAssets.length === 1}
 | 
				
			||||||
              <MenuOption
 | 
					              <MenuOption
 | 
				
			||||||
 | 
				
			|||||||
@ -3,6 +3,7 @@
 | 
				
			|||||||
  import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte';
 | 
					  import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte';
 | 
				
			||||||
  import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte';
 | 
					  import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte';
 | 
				
			||||||
  import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte';
 | 
					  import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte';
 | 
				
			||||||
 | 
					  import ChangeDescription from '$lib/components/photos-page/actions/change-description-action.svelte';
 | 
				
			||||||
  import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.svelte';
 | 
					  import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.svelte';
 | 
				
			||||||
  import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte';
 | 
					  import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte';
 | 
				
			||||||
  import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
 | 
					  import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
 | 
				
			||||||
@ -59,6 +60,7 @@
 | 
				
			|||||||
    <ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
 | 
					    <ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
 | 
				
			||||||
      <DownloadAction menuItem />
 | 
					      <DownloadAction menuItem />
 | 
				
			||||||
      <ChangeDate menuItem />
 | 
					      <ChangeDate menuItem />
 | 
				
			||||||
 | 
					      <ChangeDescription menuItem />
 | 
				
			||||||
      <ChangeLocation menuItem />
 | 
					      <ChangeLocation menuItem />
 | 
				
			||||||
      <ArchiveAction
 | 
					      <ArchiveAction
 | 
				
			||||||
        menuItem
 | 
					        menuItem
 | 
				
			||||||
 | 
				
			|||||||
@ -8,6 +8,7 @@
 | 
				
			|||||||
  import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte';
 | 
					  import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte';
 | 
				
			||||||
  import AssetJobActions from '$lib/components/photos-page/actions/asset-job-actions.svelte';
 | 
					  import AssetJobActions from '$lib/components/photos-page/actions/asset-job-actions.svelte';
 | 
				
			||||||
  import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte';
 | 
					  import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte';
 | 
				
			||||||
 | 
					  import ChangeDescription from '$lib/components/photos-page/actions/change-description-action.svelte';
 | 
				
			||||||
  import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.svelte';
 | 
					  import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.svelte';
 | 
				
			||||||
  import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte';
 | 
					  import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte';
 | 
				
			||||||
  import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
 | 
					  import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
 | 
				
			||||||
@ -115,6 +116,7 @@
 | 
				
			|||||||
      <ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
 | 
					      <ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
 | 
				
			||||||
        <DownloadAction menuItem />
 | 
					        <DownloadAction menuItem />
 | 
				
			||||||
        <ChangeDate menuItem />
 | 
					        <ChangeDate menuItem />
 | 
				
			||||||
 | 
					        <ChangeDescription menuItem />
 | 
				
			||||||
        <ChangeLocation menuItem />
 | 
					        <ChangeLocation menuItem />
 | 
				
			||||||
        <ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} onArchive={triggerAssetUpdate} />
 | 
					        <ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} onArchive={triggerAssetUpdate} />
 | 
				
			||||||
        {#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
 | 
					        {#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
 | 
				
			||||||
 | 
				
			|||||||
@ -11,6 +11,7 @@
 | 
				
			|||||||
  import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte';
 | 
					  import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte';
 | 
				
			||||||
  import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte';
 | 
					  import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte';
 | 
				
			||||||
  import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte';
 | 
					  import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte';
 | 
				
			||||||
 | 
					  import ChangeDescription from '$lib/components/photos-page/actions/change-description-action.svelte';
 | 
				
			||||||
  import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.svelte';
 | 
					  import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.svelte';
 | 
				
			||||||
  import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte';
 | 
					  import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte';
 | 
				
			||||||
  import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
 | 
					  import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
 | 
				
			||||||
@ -328,6 +329,7 @@
 | 
				
			|||||||
      return;
 | 
					      return;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    person = updatedPerson;
 | 
				
			||||||
    people = people.map((person: PersonResponseDto) => {
 | 
					    people = people.map((person: PersonResponseDto) => {
 | 
				
			||||||
      if (person.id === updatedPerson.id) {
 | 
					      if (person.id === updatedPerson.id) {
 | 
				
			||||||
        return updatedPerson;
 | 
					        return updatedPerson;
 | 
				
			||||||
@ -514,6 +516,7 @@
 | 
				
			|||||||
          onClick={handleReassignAssets}
 | 
					          onClick={handleReassignAssets}
 | 
				
			||||||
        />
 | 
					        />
 | 
				
			||||||
        <ChangeDate menuItem />
 | 
					        <ChangeDate menuItem />
 | 
				
			||||||
 | 
					        <ChangeDescription menuItem />
 | 
				
			||||||
        <ChangeLocation menuItem />
 | 
					        <ChangeLocation menuItem />
 | 
				
			||||||
        <ArchiveAction
 | 
					        <ArchiveAction
 | 
				
			||||||
          menuItem
 | 
					          menuItem
 | 
				
			||||||
 | 
				
			|||||||
@ -5,6 +5,7 @@
 | 
				
			|||||||
  import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte';
 | 
					  import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte';
 | 
				
			||||||
  import AssetJobActions from '$lib/components/photos-page/actions/asset-job-actions.svelte';
 | 
					  import AssetJobActions from '$lib/components/photos-page/actions/asset-job-actions.svelte';
 | 
				
			||||||
  import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte';
 | 
					  import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte';
 | 
				
			||||||
 | 
					  import ChangeDescription from '$lib/components/photos-page/actions/change-description-action.svelte';
 | 
				
			||||||
  import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.svelte';
 | 
					  import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.svelte';
 | 
				
			||||||
  import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte';
 | 
					  import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte';
 | 
				
			||||||
  import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
 | 
					  import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
 | 
				
			||||||
@ -142,6 +143,7 @@
 | 
				
			|||||||
        />
 | 
					        />
 | 
				
			||||||
      {/if}
 | 
					      {/if}
 | 
				
			||||||
      <ChangeDate menuItem />
 | 
					      <ChangeDate menuItem />
 | 
				
			||||||
 | 
					      <ChangeDescription menuItem />
 | 
				
			||||||
      <ChangeLocation menuItem />
 | 
					      <ChangeLocation menuItem />
 | 
				
			||||||
      <ArchiveAction menuItem onArchive={(assetIds) => assetStore.removeAssets(assetIds)} />
 | 
					      <ArchiveAction menuItem onArchive={(assetIds) => assetStore.removeAssets(assetIds)} />
 | 
				
			||||||
      {#if $preferences.tags.enabled}
 | 
					      {#if $preferences.tags.enabled}
 | 
				
			||||||
 | 
				
			|||||||
@ -9,6 +9,7 @@
 | 
				
			|||||||
  import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte';
 | 
					  import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte';
 | 
				
			||||||
  import AssetJobActions from '$lib/components/photos-page/actions/asset-job-actions.svelte';
 | 
					  import AssetJobActions from '$lib/components/photos-page/actions/asset-job-actions.svelte';
 | 
				
			||||||
  import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte';
 | 
					  import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte';
 | 
				
			||||||
 | 
					  import ChangeDescription from '$lib/components/photos-page/actions/change-description-action.svelte';
 | 
				
			||||||
  import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.svelte';
 | 
					  import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.svelte';
 | 
				
			||||||
  import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte';
 | 
					  import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte';
 | 
				
			||||||
  import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
 | 
					  import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
 | 
				
			||||||
@ -249,57 +250,6 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
<svelte:window use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onEscape }} bind:scrollY />
 | 
					<svelte:window use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onEscape }} bind:scrollY />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<section>
 | 
					 | 
				
			||||||
  {#if assetInteraction.selectionActive}
 | 
					 | 
				
			||||||
    <div class="fixed top-0 start-0 w-full">
 | 
					 | 
				
			||||||
      <AssetSelectControlBar
 | 
					 | 
				
			||||||
        assets={assetInteraction.selectedAssets}
 | 
					 | 
				
			||||||
        clearSelect={() => cancelMultiselect(assetInteraction)}
 | 
					 | 
				
			||||||
      >
 | 
					 | 
				
			||||||
        <CreateSharedLink />
 | 
					 | 
				
			||||||
        <CircleIconButton title={$t('select_all')} icon={mdiSelectAll} onclick={handleSelectAll} />
 | 
					 | 
				
			||||||
        <ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
 | 
					 | 
				
			||||||
          <AddToAlbum {onAddToAlbum} />
 | 
					 | 
				
			||||||
          <AddToAlbum shared {onAddToAlbum} />
 | 
					 | 
				
			||||||
        </ButtonContextMenu>
 | 
					 | 
				
			||||||
        <FavoriteAction
 | 
					 | 
				
			||||||
          removeFavorite={assetInteraction.isAllFavorite}
 | 
					 | 
				
			||||||
          onFavorite={(ids, isFavorite) => {
 | 
					 | 
				
			||||||
            for (const id of ids) {
 | 
					 | 
				
			||||||
              const asset = searchResultAssets.find((asset) => asset.id === id);
 | 
					 | 
				
			||||||
              if (asset) {
 | 
					 | 
				
			||||||
                asset.isFavorite = isFavorite;
 | 
					 | 
				
			||||||
              }
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
          }}
 | 
					 | 
				
			||||||
        />
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        <ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
 | 
					 | 
				
			||||||
          <DownloadAction menuItem />
 | 
					 | 
				
			||||||
          <ChangeDate menuItem />
 | 
					 | 
				
			||||||
          <ChangeLocation menuItem />
 | 
					 | 
				
			||||||
          <ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} />
 | 
					 | 
				
			||||||
          {#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
 | 
					 | 
				
			||||||
            <TagAction menuItem />
 | 
					 | 
				
			||||||
          {/if}
 | 
					 | 
				
			||||||
          <DeleteAssets menuItem {onAssetDelete} />
 | 
					 | 
				
			||||||
          <hr />
 | 
					 | 
				
			||||||
          <AssetJobActions />
 | 
					 | 
				
			||||||
        </ButtonContextMenu>
 | 
					 | 
				
			||||||
      </AssetSelectControlBar>
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
  {:else}
 | 
					 | 
				
			||||||
    <div class="fixed top-0 start-0 w-full">
 | 
					 | 
				
			||||||
      <ControlAppBar onClose={() => goto(previousRoute)} backIcon={mdiArrowLeft}>
 | 
					 | 
				
			||||||
        <div class="absolute bg-light"></div>
 | 
					 | 
				
			||||||
        <div class="w-full flex-1 ps-4">
 | 
					 | 
				
			||||||
          <SearchBar grayTheme={false} value={terms?.query ?? ''} searchQuery={terms} />
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      </ControlAppBar>
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
  {/if}
 | 
					 | 
				
			||||||
</section>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
{#if terms}
 | 
					{#if terms}
 | 
				
			||||||
  <section
 | 
					  <section
 | 
				
			||||||
    id="search-chips"
 | 
					    id="search-chips"
 | 
				
			||||||
@ -380,4 +330,56 @@
 | 
				
			|||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    {/if}
 | 
					    {/if}
 | 
				
			||||||
  </section>
 | 
					  </section>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  <section>
 | 
				
			||||||
 | 
					    {#if assetInteraction.selectionActive}
 | 
				
			||||||
 | 
					      <div class="fixed top-0 start-0 w-full">
 | 
				
			||||||
 | 
					        <AssetSelectControlBar
 | 
				
			||||||
 | 
					          assets={assetInteraction.selectedAssets}
 | 
				
			||||||
 | 
					          clearSelect={() => cancelMultiselect(assetInteraction)}
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          <CreateSharedLink />
 | 
				
			||||||
 | 
					          <CircleIconButton title={$t('select_all')} icon={mdiSelectAll} onclick={handleSelectAll} />
 | 
				
			||||||
 | 
					          <ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
 | 
				
			||||||
 | 
					            <AddToAlbum {onAddToAlbum} />
 | 
				
			||||||
 | 
					            <AddToAlbum shared {onAddToAlbum} />
 | 
				
			||||||
 | 
					          </ButtonContextMenu>
 | 
				
			||||||
 | 
					          <FavoriteAction
 | 
				
			||||||
 | 
					            removeFavorite={assetInteraction.isAllFavorite}
 | 
				
			||||||
 | 
					            onFavorite={(ids, isFavorite) => {
 | 
				
			||||||
 | 
					              for (const id of ids) {
 | 
				
			||||||
 | 
					                const asset = searchResultAssets.find((asset) => asset.id === id);
 | 
				
			||||||
 | 
					                if (asset) {
 | 
				
			||||||
 | 
					                  asset.isFavorite = isFavorite;
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					            }}
 | 
				
			||||||
 | 
					          />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          <ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
 | 
				
			||||||
 | 
					            <DownloadAction menuItem />
 | 
				
			||||||
 | 
					            <ChangeDate menuItem />
 | 
				
			||||||
 | 
					            <ChangeDescription menuItem />
 | 
				
			||||||
 | 
					            <ChangeLocation menuItem />
 | 
				
			||||||
 | 
					            <ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} />
 | 
				
			||||||
 | 
					            {#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
 | 
				
			||||||
 | 
					              <TagAction menuItem />
 | 
				
			||||||
 | 
					            {/if}
 | 
				
			||||||
 | 
					            <DeleteAssets menuItem {onAssetDelete} />
 | 
				
			||||||
 | 
					            <hr />
 | 
				
			||||||
 | 
					            <AssetJobActions />
 | 
				
			||||||
 | 
					          </ButtonContextMenu>
 | 
				
			||||||
 | 
					        </AssetSelectControlBar>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    {:else}
 | 
				
			||||||
 | 
					      <div class="fixed top-0 start-0 w-full">
 | 
				
			||||||
 | 
					        <ControlAppBar onClose={() => goto(previousRoute)} backIcon={mdiArrowLeft}>
 | 
				
			||||||
 | 
					          <div class="absolute bg-light"></div>
 | 
				
			||||||
 | 
					          <div class="w-full flex-1 ps-4">
 | 
				
			||||||
 | 
					            <SearchBar grayTheme={false} value={terms?.query ?? ''} searchQuery={terms} />
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        </ControlAppBar>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    {/if}
 | 
				
			||||||
 | 
					  </section>
 | 
				
			||||||
</section>
 | 
					</section>
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user