mirror of
https://github.com/immich-app/immich.git
synced 2025-05-24 01:12:58 -04: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 { 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', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let visiblePerson: PersonResponseDto;
|
||||
@ -58,14 +42,6 @@ describe('/people', () => {
|
||||
|
||||
describe('GET /people', () => {
|
||||
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 () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/people')
|
||||
@ -117,13 +93,6 @@ describe('/people', () => {
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const { status, body } = await request(app)
|
||||
.get(`/people/${uuidDto.notFound}`)
|
||||
@ -144,13 +113,6 @@ describe('/people', () => {
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const { status, body } = await request(app)
|
||||
.get(`/people/${uuidDto.notFound}/statistics`)
|
||||
@ -171,23 +133,6 @@ describe('/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 () => {
|
||||
const { status, body } = await request(app)
|
||||
.post(`/people`)
|
||||
@ -223,39 +168,6 @@ describe('/people', () => {
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/people/${visiblePerson.id}`)
|
||||
@ -312,12 +224,6 @@ describe('/people', () => {
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const { status, body } = await request(app)
|
||||
.post(`/people/${visiblePerson.id}/merge`)
|
||||
|
@ -601,6 +601,7 @@
|
||||
"cannot_undo_this_action": "You cannot undo this action!",
|
||||
"cannot_update_the_description": "Cannot update the description",
|
||||
"change_date": "Change date",
|
||||
"change_description": "Change description",
|
||||
"change_display_order": "Change display order",
|
||||
"change_expiration_time": "Change expiration time",
|
||||
"change_location": "Change location",
|
||||
@ -794,6 +795,8 @@
|
||||
"edit_avatar": "Edit avatar",
|
||||
"edit_date": "Edit date",
|
||||
"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_faces": "Edit faces",
|
||||
"edit_import_path": "Edit import path",
|
||||
@ -882,6 +885,7 @@
|
||||
"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_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_location": "Unable to change location",
|
||||
"unable_to_change_password": "Unable to change password",
|
||||
|
@ -8,10 +8,13 @@ FROM builder-cpu AS builder-cuda
|
||||
|
||||
FROM builder-cpu AS builder-armnn
|
||||
|
||||
# renovate: datasource=github-releases depName=ARM-software/armnn
|
||||
ARG ARMNN_VERSION="v24.05"
|
||||
|
||||
ENV ARMNN_PATH=/opt/armnn
|
||||
COPY ann /opt/ann
|
||||
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 && \
|
||||
sh build.sh
|
||||
|
||||
@ -21,6 +24,8 @@ FROM builder-cpu AS builder-rknn
|
||||
# TODO: find a way to reduce the image size
|
||||
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
|
||||
|
||||
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}
|
||||
|
||||
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
|
||||
# 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
|
||||
@ -42,7 +47,7 @@ RUN git apply /tmp/*.patch
|
||||
RUN /bin/sh ./dockerfiles/scripts/install_common_deps.sh
|
||||
# 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\
|
||||
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/
|
||||
|
||||
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-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 && \
|
||||
# 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 && \
|
||||
dpkg -i *.deb && \
|
||||
rm *.deb && \
|
||||
@ -118,9 +124,12 @@ COPY --from=builder-armnn \
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
|
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.
|
||||
AssetBulkUpdateDto({
|
||||
this.dateTimeOriginal,
|
||||
this.description,
|
||||
this.duplicateId,
|
||||
this.ids = const [],
|
||||
this.isFavorite,
|
||||
@ -31,6 +32,14 @@ class AssetBulkUpdateDto {
|
||||
///
|
||||
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;
|
||||
|
||||
List<String> ids;
|
||||
@ -80,6 +89,7 @@ class AssetBulkUpdateDto {
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is AssetBulkUpdateDto &&
|
||||
other.dateTimeOriginal == dateTimeOriginal &&
|
||||
other.description == description &&
|
||||
other.duplicateId == duplicateId &&
|
||||
_deepEquality.equals(other.ids, ids) &&
|
||||
other.isFavorite == isFavorite &&
|
||||
@ -92,6 +102,7 @@ class AssetBulkUpdateDto {
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(dateTimeOriginal == null ? 0 : dateTimeOriginal!.hashCode) +
|
||||
(description == null ? 0 : description!.hashCode) +
|
||||
(duplicateId == null ? 0 : duplicateId!.hashCode) +
|
||||
(ids.hashCode) +
|
||||
(isFavorite == null ? 0 : isFavorite!.hashCode) +
|
||||
@ -101,7 +112,7 @@ class AssetBulkUpdateDto {
|
||||
(visibility == null ? 0 : visibility!.hashCode);
|
||||
|
||||
@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() {
|
||||
final json = <String, dynamic>{};
|
||||
@ -110,6 +121,11 @@ class AssetBulkUpdateDto {
|
||||
} else {
|
||||
// json[r'dateTimeOriginal'] = null;
|
||||
}
|
||||
if (this.description != null) {
|
||||
json[r'description'] = this.description;
|
||||
} else {
|
||||
// json[r'description'] = null;
|
||||
}
|
||||
if (this.duplicateId != null) {
|
||||
json[r'duplicateId'] = this.duplicateId;
|
||||
} else {
|
||||
@ -154,6 +170,7 @@ class AssetBulkUpdateDto {
|
||||
|
||||
return AssetBulkUpdateDto(
|
||||
dateTimeOriginal: mapValueOfType<String>(json, r'dateTimeOriginal'),
|
||||
description: mapValueOfType<String>(json, r'description'),
|
||||
duplicateId: mapValueOfType<String>(json, r'duplicateId'),
|
||||
ids: json[r'ids'] is Iterable
|
||||
? (json[r'ids'] as Iterable).cast<String>().toList(growable: false)
|
||||
|
@ -8605,6 +8605,9 @@
|
||||
"dateTimeOriginal": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"duplicateId": {
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
@ -11075,6 +11078,7 @@
|
||||
},
|
||||
"featureFaceAssetId": {
|
||||
"description": "Asset is used to get the feature face thumbnail.",
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
@ -11280,6 +11284,7 @@
|
||||
},
|
||||
"featureFaceAssetId": {
|
||||
"description": "Asset is used to get the feature face thumbnail.",
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
},
|
||||
"isFavorite": {
|
||||
|
@ -431,6 +431,7 @@ export type AssetMediaResponseDto = {
|
||||
};
|
||||
export type AssetBulkUpdateDto = {
|
||||
dateTimeOriginal?: string;
|
||||
description?: string;
|
||||
duplicateId?: string | null;
|
||||
ids: string[];
|
||||
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(
|
||||
private service: PersonService,
|
||||
private logger: LoggingRepository,
|
||||
) {}
|
||||
) {
|
||||
this.logger.setContext(PersonController.name);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@Authenticated({ permission: Permission.PERSON_READ })
|
||||
|
@ -54,6 +54,10 @@ export class UpdateAssetBase {
|
||||
@Max(5)
|
||||
@Min(-1)
|
||||
rating?: number;
|
||||
|
||||
@Optional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export class AssetBulkUpdateDto extends UpdateAssetBase {
|
||||
@ -65,10 +69,6 @@ export class AssetBulkUpdateDto extends UpdateAssetBase {
|
||||
}
|
||||
|
||||
export class UpdateAssetDto extends UpdateAssetBase {
|
||||
@Optional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@ValidateUUID({ optional: true, nullable: true })
|
||||
livePhotoVideoId?: string | null;
|
||||
}
|
||||
|
@ -33,7 +33,7 @@ export class PersonCreateDto {
|
||||
@ApiProperty({ format: 'date' })
|
||||
@MaxDateString(() => DateTime.now(), { message: 'Birth date cannot be in the future' })
|
||||
@IsDateStringFormat('yyyy-MM-dd')
|
||||
@Optional({ nullable: true })
|
||||
@Optional({ nullable: true, emptyToNull: true })
|
||||
birthDate?: Date | null;
|
||||
|
||||
/**
|
||||
@ -54,8 +54,7 @@ export class PersonUpdateDto extends PersonCreateDto {
|
||||
/**
|
||||
* Asset is used to get the feature face thumbnail.
|
||||
*/
|
||||
@Optional()
|
||||
@IsString()
|
||||
@ValidateUUID({ optional: true })
|
||||
featureFaceAssetId?: string;
|
||||
}
|
||||
|
||||
|
@ -567,6 +567,7 @@ export enum DatabaseLock {
|
||||
Library = 1337,
|
||||
GetSystemConfig = 69,
|
||||
BackupDatabase = 42,
|
||||
MemoryCreation = 777,
|
||||
}
|
||||
|
||||
export enum SyncRequestType {
|
||||
|
@ -108,13 +108,21 @@ export class AssetService extends BaseService {
|
||||
}
|
||||
|
||||
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 });
|
||||
|
||||
if (dateTimeOriginal !== undefined || latitude !== undefined || longitude !== undefined) {
|
||||
await this.assetRepository.updateAllExif(ids, { dateTimeOriginal, latitude, longitude });
|
||||
if (
|
||||
description !== undefined ||
|
||||
dateTimeOriginal !== undefined ||
|
||||
latitude !== undefined ||
|
||||
longitude !== undefined
|
||||
) {
|
||||
await this.assetRepository.updateAllExif(ids, { description, dateTimeOriginal, latitude, longitude });
|
||||
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 { AuthDto } from 'src/dtos/auth.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 { OnThisDayData } from 'src/types';
|
||||
import { addAssets, getMyPartnerIds, removeAssets } from 'src/utils/asset.util';
|
||||
|
||||
const DAYS = 3;
|
||||
@ -16,55 +15,61 @@ export class MemoryService extends BaseService {
|
||||
@OnJob({ name: JobName.MEMORIES_CREATE, queue: QueueName.BACKGROUND_TASK })
|
||||
async onMemoriesCreate() {
|
||||
const users = await this.userRepository.getList({ withDeleted: false });
|
||||
const userMap: Record<string, string[]> = {};
|
||||
for (const user of users) {
|
||||
const partnerIds = await getMyPartnerIds({
|
||||
userId: user.id,
|
||||
repository: this.partnerRepository,
|
||||
timelineEnabled: true,
|
||||
});
|
||||
userMap[user.id] = [user.id, ...partnerIds];
|
||||
}
|
||||
const usersIds = await Promise.all(
|
||||
users.map((user) =>
|
||||
getMyPartnerIds({
|
||||
userId: user.id,
|
||||
repository: this.partnerRepository,
|
||||
timelineEnabled: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
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);
|
||||
const lastOnThisDayDate = state?.lastOnThisDayDate ? DateTime.fromISO(state.lastOnThisDayDate) : start;
|
||||
|
||||
// generate a memory +/- X days from today
|
||||
for (let i = 0; i <= DAYS * 2; i++) {
|
||||
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)),
|
||||
);
|
||||
// generate a memory +/- X days from today
|
||||
for (let i = 0; i <= DAYS * 2; i++) {
|
||||
const target = start.plus({ days: i });
|
||||
if (lastOnThisDayDate >= target) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
await this.systemMetadataRepository.set(SystemMetadataKey.MEMORIES_STATE, {
|
||||
...state,
|
||||
lastOnThisDayDate: target.toISO(),
|
||||
});
|
||||
}
|
||||
try {
|
||||
await Promise.all(users.map((owner, i) => this.createOnThisDayMemories(owner.id, usersIds[i], target)));
|
||||
} 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 })
|
||||
|
@ -15,6 +15,7 @@ describe(MemoryService.name, () => {
|
||||
database: db || defaultDatabase,
|
||||
repos: {
|
||||
asset: 'real',
|
||||
database: 'real',
|
||||
memory: 'real',
|
||||
user: '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 ArchiveAction from '$lib/components/photos-page/actions/archive-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 CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte';
|
||||
import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
|
||||
@ -323,6 +324,7 @@
|
||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||
<DownloadAction menuItem />
|
||||
<ChangeDate menuItem />
|
||||
<ChangeDescription menuItem />
|
||||
<ChangeLocation menuItem />
|
||||
<ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} onArchive={handleDeleteOrArchiveAssets} />
|
||||
{#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">
|
||||
import Checkbox from '$lib/components/elements/checkbox.svelte';
|
||||
import FormatMessage from '$lib/components/i18n/format-message.svelte';
|
||||
import ConfirmModal from '$lib/modals/ConfirmModal.svelte';
|
||||
import { showDeleteModal } from '$lib/stores/preferences.store';
|
||||
import { Checkbox, Label } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
@ -38,8 +38,9 @@
|
||||
</p>
|
||||
<p><b>{$t('cannot_undo_this_action')}</b></p>
|
||||
|
||||
<div class="pt-4 flex justify-center items-center">
|
||||
<Checkbox id="confirm-deletion-input" label={$t('do_not_show_again')} bind:checked />
|
||||
<div class="pt-4 flex justify-center items-center gap-2">
|
||||
<Checkbox id="confirm-deletion-input" bind:checked color="secondary" />
|
||||
<Label label={$t('do_not_show_again')} for="confirm-deletion-input" />
|
||||
</div>
|
||||
{/snippet}
|
||||
</ConfirmModal>
|
||||
|
@ -1,6 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { Theme } from '$lib/constants';
|
||||
import { themeManager } from '$lib/managers/theme-manager.svelte';
|
||||
import QRCode from 'qrcode';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
@ -12,13 +10,7 @@
|
||||
|
||||
const { value, width, alt = $t('alt_text_qr_code') }: Props = $props();
|
||||
|
||||
let promise = $derived(
|
||||
QRCode.toDataURL(value, {
|
||||
color: { dark: themeManager.value === Theme.DARK ? '#ffffffff' : '#000000ff', light: '#00000000' },
|
||||
margin: 0,
|
||||
width,
|
||||
}),
|
||||
);
|
||||
let promise = $derived(QRCode.toDataURL(value, { margin: 0, width }));
|
||||
</script>
|
||||
|
||||
<div style="width: {width}px; height: {width}px">
|
||||
|
@ -1,13 +1,14 @@
|
||||
<script lang="ts" module>
|
||||
export interface SearchDisplayFilters {
|
||||
isNotInAlbum?: boolean;
|
||||
isArchive?: boolean;
|
||||
isFavorite?: boolean;
|
||||
isNotInAlbum: boolean;
|
||||
isArchive: boolean;
|
||||
isFavorite: boolean;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import Checkbox from '$lib/components/elements/checkbox.svelte';
|
||||
import { Checkbox, Label } from '@immich/ui';
|
||||
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
@ -21,9 +22,18 @@
|
||||
<fieldset>
|
||||
<legend class="immich-form-label">{$t('display_options').toUpperCase()}</legend>
|
||||
<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} />
|
||||
<Checkbox id="archive-checkbox" label={$t('archive')} bind:checked={filters.isArchive} />
|
||||
<Checkbox id="favorite-checkbox" label={$t('favorites')} bind:checked={filters.isFavorite} />
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox id="not-in-album-checkbox" size="tiny" bind:checked={filters.isNotInAlbum} />
|
||||
<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>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
@ -1,8 +1,8 @@
|
||||
<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 { fly } from 'svelte/transition';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
value: string[];
|
||||
@ -52,14 +52,18 @@
|
||||
{/if}
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each options as option (option.value)}
|
||||
<Checkbox
|
||||
id="{option.value}-checkbox"
|
||||
label={option.text}
|
||||
checked={value.includes(option.value)}
|
||||
{disabled}
|
||||
labelClass="text-gray-500 dark:text-gray-300"
|
||||
onchange={() => handleCheckboxChange(option.value)}
|
||||
/>
|
||||
<div class="flex gap-2 items-center">
|
||||
<Checkbox
|
||||
size="tiny"
|
||||
id="{option.value}-checkbox"
|
||||
checked={value.includes(option.value)}
|
||||
{disabled}
|
||||
onCheckedChange={() => handleCheckboxChange(option.value)}
|
||||
/>
|
||||
<Label label={option.text} for="{option.value}-checkbox">
|
||||
{option.text}
|
||||
</Label>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,10 +1,10 @@
|
||||
<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 { 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 {
|
||||
title: string;
|
||||
@ -54,5 +54,5 @@
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
<Slider id={sliderId} bind:checked {disabled} {onToggle} ariaDescribedBy={subtitleId} />
|
||||
<Switch id={sliderId} bind:checked {disabled} onCheckedChange={onToggle} aria-describedby={subtitleId} />
|
||||
</div>
|
||||
|
@ -25,6 +25,13 @@
|
||||
|
||||
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> = {
|
||||
[SlideshowNavigation.Shuffle]: { icon: mdiShuffle, title: $t('shuffle') },
|
||||
[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>
|
||||
|
||||
<FullScreenModal title={$t('slideshow_settings')} onClose={() => onClose()}>
|
||||
@ -54,31 +70,32 @@
|
||||
<SettingDropdown
|
||||
title={$t('direction')}
|
||||
options={Object.values(navigationOptions)}
|
||||
selectedOption={navigationOptions[$slideshowNavigation]}
|
||||
selectedOption={navigationOptions[tempSlideshowNavigation]}
|
||||
onToggle={(option) => {
|
||||
$slideshowNavigation = handleToggle(option, navigationOptions) || $slideshowNavigation;
|
||||
tempSlideshowNavigation = handleToggle(option, navigationOptions) || tempSlideshowNavigation;
|
||||
}}
|
||||
/>
|
||||
<SettingDropdown
|
||||
title={$t('look')}
|
||||
options={Object.values(lookOptions)}
|
||||
selectedOption={lookOptions[$slideshowLook]}
|
||||
selectedOption={lookOptions[tempSlideshowLook]}
|
||||
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_slideshow_transition')} bind:checked={$slideshowTransition} />
|
||||
<SettingSwitch title={$t('show_progress_bar')} bind:checked={tempShowProgressBar} />
|
||||
<SettingSwitch title={$t('show_slideshow_transition')} bind:checked={tempSlideshowTransition} />
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label={$t('duration')}
|
||||
description={$t('admin.slideshow_duration_description')}
|
||||
min={1}
|
||||
bind:value={$slideshowDelay}
|
||||
bind:value={tempSlideshowDelay}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#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}
|
||||
</FullScreenModal>
|
||||
|
@ -18,6 +18,7 @@
|
||||
import { fade } from 'svelte/transition';
|
||||
import { handleError } from '../../utils/handle-error';
|
||||
import { notificationController, NotificationType } from '../shared-components/notification/notification';
|
||||
import { dateFormats } from '$lib/constants';
|
||||
|
||||
interface Props {
|
||||
keys: ApiKeyResponseDto[];
|
||||
@ -25,12 +26,6 @@
|
||||
|
||||
let { keys = $bindable() }: Props = $props();
|
||||
|
||||
const format: Intl.DateTimeFormatOptions = {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
};
|
||||
|
||||
async function refreshKeys() {
|
||||
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"
|
||||
>{new Date(key.createdAt).toLocaleDateString($locale, format)}
|
||||
>{new Date(key.createdAt).toLocaleDateString($locale, dateFormats.settings)}
|
||||
</td>
|
||||
<td class="flex flex-row flex-wrap justify-center gap-x-2 gap-y-1 w-1/3">
|
||||
<CircleIconButton
|
||||
|
@ -4,7 +4,9 @@
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import PurchaseContent from '$lib/components/shared-components/purchasing/purchase-content.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 { locale } from '$lib/stores/preferences.store';
|
||||
import { purchaseStore } from '$lib/stores/purchase.store';
|
||||
import { preferences, user } from '$lib/stores/user.store';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
@ -132,7 +134,9 @@
|
||||
{#if $user.isAdmin && serverPurchaseInfo?.activatedAt}
|
||||
<p class="dark:text-white text-sm mt-1 col-start-2">
|
||||
{$t('purchase_activated_time', {
|
||||
values: { date: new Date(serverPurchaseInfo.activatedAt) },
|
||||
values: {
|
||||
date: new Date(serverPurchaseInfo.activatedAt).toLocaleString($locale, dateFormats.settings),
|
||||
},
|
||||
})}
|
||||
</p>
|
||||
{:else}
|
||||
@ -161,7 +165,9 @@
|
||||
{#if $user.license?.activatedAt}
|
||||
<p class="dark:text-white text-sm mt-1 col-start-2">
|
||||
{$t('purchase_activated_time', {
|
||||
values: { date: new Date($user.license?.activatedAt) },
|
||||
values: {
|
||||
date: new Date($user.license?.activatedAt).toLocaleString($locale, dateFormats.settings),
|
||||
},
|
||||
})}
|
||||
</p>
|
||||
{/if}
|
||||
|
@ -72,6 +72,11 @@ export const dateFormats = {
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
},
|
||||
settings: <Intl.DateTimeFormatOptions>{
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
},
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
<Modal {title} onClose={() => onClose(false)} {size} class="bg-light text-dark">
|
||||
<Modal {title} onClose={() => onClose(false)} {size}>
|
||||
<ModalBody>
|
||||
{#if promptSnippet}{@render promptSnippet()}{:else}
|
||||
<p>{prompt}</p>
|
||||
|
@ -12,13 +12,7 @@
|
||||
const { onClose, newPassword }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
title={$t('password_reset_success')}
|
||||
icon={mdiCheck}
|
||||
onClose={() => onClose()}
|
||||
size="small"
|
||||
class="bg-light text-dark"
|
||||
>
|
||||
<Modal title={$t('password_reset_success')} icon={mdiCheck} onClose={() => onClose()} size="small">
|
||||
<ModalBody>
|
||||
<div class="flex flex-col gap-4">
|
||||
<Text>{$t('admin.user_password_has_been_reset')}</Text>
|
||||
|
@ -24,7 +24,7 @@
|
||||
try {
|
||||
const updatedPerson = await updatePerson({
|
||||
id: person.id,
|
||||
personUpdateDto: { birthDate: birthDate.length > 0 ? birthDate : null },
|
||||
personUpdateDto: { birthDate },
|
||||
});
|
||||
|
||||
notificationController.show({ message: $t('date_of_birth_saved'), type: NotificationType.Info });
|
||||
@ -53,6 +53,13 @@
|
||||
bind:value={birthDate}
|
||||
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>
|
||||
</form>
|
||||
</ModalBody>
|
||||
@ -62,8 +69,8 @@
|
||||
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>
|
||||
{$t('cancel')}
|
||||
</Button>
|
||||
<Button type="submit" shape="round" color="primary" fullWidth>
|
||||
{$t('set')}
|
||||
<Button type="submit" shape="round" color="primary" fullWidth form="set-birth-date-form">
|
||||
{$t('save')}
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
|
@ -84,8 +84,8 @@
|
||||
},
|
||||
display: {
|
||||
isArchive: searchQuery.visibility === AssetVisibility.Archive,
|
||||
isFavorite: searchQuery.isFavorite,
|
||||
isNotInAlbum: 'isNotInAlbum' in searchQuery ? searchQuery.isNotInAlbum : undefined,
|
||||
isFavorite: searchQuery.isFavorite ?? false,
|
||||
isNotInAlbum: 'isNotInAlbum' in searchQuery ? (searchQuery.isNotInAlbum ?? false) : false,
|
||||
},
|
||||
mediaType:
|
||||
searchQuery.type === AssetTypeEnum.Image
|
||||
@ -105,7 +105,11 @@
|
||||
location: {},
|
||||
camera: {},
|
||||
date: {},
|
||||
display: {},
|
||||
display: {
|
||||
isArchive: false,
|
||||
isFavorite: false,
|
||||
isNotInAlbum: false,
|
||||
},
|
||||
mediaType: MediaType.All,
|
||||
rating: undefined,
|
||||
};
|
||||
|
@ -81,7 +81,7 @@
|
||||
};
|
||||
</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>
|
||||
<form onsubmit={onSubmit} autocomplete="off" id="create-new-user-form">
|
||||
{#if error}
|
||||
|
@ -1,10 +1,10 @@
|
||||
<script lang="ts">
|
||||
import Checkbox from '$lib/components/elements/checkbox.svelte';
|
||||
import FormatMessage from '$lib/components/i18n/format-message.svelte';
|
||||
import ConfirmModal from '$lib/modals/ConfirmModal.svelte';
|
||||
import { serverConfig } from '$lib/stores/server-config.store';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { deleteUserAdmin, type UserAdminResponseDto, type UserResponseDto } from '@immich/sdk';
|
||||
import { Checkbox, Label } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
@ -66,16 +66,14 @@
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-center m-4 gap-2">
|
||||
<div class="flex justify-center items-center gap-2">
|
||||
<Checkbox
|
||||
id="queue-user-deletion-checkbox"
|
||||
label={$t('admin.user_delete_immediately_checkbox')}
|
||||
labelClass="text-sm dark:text-immich-dark-fg"
|
||||
color="secondary"
|
||||
bind:checked={forceDelete}
|
||||
onchange={() => {
|
||||
deleteButtonDisabled = forceDelete;
|
||||
}}
|
||||
onCheckedChange={() => (deleteButtonDisabled = forceDelete)}
|
||||
/>
|
||||
<Label label={$t('admin.user_delete_immediately_checkbox')} for="queue-user-deletion-checkbox" />
|
||||
</div>
|
||||
|
||||
{#if forceDelete}
|
||||
|
@ -51,7 +51,7 @@
|
||||
};
|
||||
</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>
|
||||
<form onsubmit={onSubmit} autocomplete="off" id="edit-user-form">
|
||||
<div class="mb-4 flex flex-col gap-2">
|
||||
|
@ -23,7 +23,7 @@
|
||||
};
|
||||
</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>
|
||||
<p>
|
||||
<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 ArchiveAction from '$lib/components/photos-page/actions/archive-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 CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte';
|
||||
import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
|
||||
@ -478,6 +479,7 @@
|
||||
<DownloadAction menuItem filename="{album.albumName}.zip" />
|
||||
{#if assetInteraction.isAllUserOwned}
|
||||
<ChangeDate menuItem />
|
||||
<ChangeDescription menuItem />
|
||||
<ChangeLocation menuItem />
|
||||
{#if assetInteraction.selectedAssets.length === 1}
|
||||
<MenuOption
|
||||
|
@ -3,6 +3,7 @@
|
||||
import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.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 ChangeDescription from '$lib/components/photos-page/actions/change-description-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 DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
|
||||
@ -59,6 +60,7 @@
|
||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||
<DownloadAction menuItem />
|
||||
<ChangeDate menuItem />
|
||||
<ChangeDescription menuItem />
|
||||
<ChangeLocation menuItem />
|
||||
<ArchiveAction
|
||||
menuItem
|
||||
|
@ -8,6 +8,7 @@
|
||||
import ArchiveAction from '$lib/components/photos-page/actions/archive-action.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 ChangeDescription from '$lib/components/photos-page/actions/change-description-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 DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
|
||||
@ -115,6 +116,7 @@
|
||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||
<DownloadAction menuItem />
|
||||
<ChangeDate menuItem />
|
||||
<ChangeDescription menuItem />
|
||||
<ChangeLocation menuItem />
|
||||
<ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} onArchive={triggerAssetUpdate} />
|
||||
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
|
||||
|
@ -11,6 +11,7 @@
|
||||
import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.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 ChangeDescription from '$lib/components/photos-page/actions/change-description-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 DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
|
||||
@ -328,6 +329,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
person = updatedPerson;
|
||||
people = people.map((person: PersonResponseDto) => {
|
||||
if (person.id === updatedPerson.id) {
|
||||
return updatedPerson;
|
||||
@ -514,6 +516,7 @@
|
||||
onClick={handleReassignAssets}
|
||||
/>
|
||||
<ChangeDate menuItem />
|
||||
<ChangeDescription menuItem />
|
||||
<ChangeLocation menuItem />
|
||||
<ArchiveAction
|
||||
menuItem
|
||||
|
@ -5,6 +5,7 @@
|
||||
import ArchiveAction from '$lib/components/photos-page/actions/archive-action.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 ChangeDescription from '$lib/components/photos-page/actions/change-description-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 DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
|
||||
@ -142,6 +143,7 @@
|
||||
/>
|
||||
{/if}
|
||||
<ChangeDate menuItem />
|
||||
<ChangeDescription menuItem />
|
||||
<ChangeLocation menuItem />
|
||||
<ArchiveAction menuItem onArchive={(assetIds) => assetStore.removeAssets(assetIds)} />
|
||||
{#if $preferences.tags.enabled}
|
||||
|
@ -9,6 +9,7 @@
|
||||
import ArchiveAction from '$lib/components/photos-page/actions/archive-action.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 ChangeDescription from '$lib/components/photos-page/actions/change-description-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 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 />
|
||||
|
||||
<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}
|
||||
<section
|
||||
id="search-chips"
|
||||
@ -380,4 +330,56 @@
|
||||
</div>
|
||||
{/if}
|
||||
</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>
|
||||
|
Loading…
x
Reference in New Issue
Block a user