Merge branch 'main' into service_worker_appstatic

This commit is contained in:
Min Idzelis 2025-05-17 22:21:49 -04:00 committed by GitHub
commit b6e1c00b29
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 528 additions and 415 deletions

View File

@ -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`)

View File

@ -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",

View File

@ -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

View File

@ -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)

View File

@ -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": {

View File

@ -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;

View 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();
});
});
});

View File

@ -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 })

View File

@ -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;
} }

View File

@ -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;
} }

View File

@ -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 {

View File

@ -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 },
})),
); );
} }

View File

@ -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,19 +15,19 @@ 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 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 lastOnThisDayDate = state?.lastOnThisDayDate ? DateTime.fromISO(state.lastOnThisDayDate) : start;
// generate a memory +/- X days from today // generate a memory +/- X days from today
@ -38,33 +37,39 @@ export class MemoryService extends BaseService {
continue; continue;
} }
const showAt = target.startOf('day').toISO(); try {
const hideAt = target.endOf('day').toISO(); await Promise.all(users.map((owner, i) => this.createOnThisDayMemories(owner.id, usersIds[i], target)));
} catch (error) {
for (const [userId, userIds] of Object.entries(userMap)) { this.logger.error(`Failed to create memories for ${target.toISO()}`, error);
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)),
);
} }
} // update system metadata even when there is an error to minimize the chance of duplicates
await this.systemMetadataRepository.set(SystemMetadataKey.MEMORIES_STATE, { await this.systemMetadataRepository.set(SystemMetadataKey.MEMORIES_STATE, {
...state, ...state,
lastOnThisDayDate: target.toISO(), 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 })

View File

@ -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',

View File

@ -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>

View File

@ -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>

View File

@ -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}

View File

@ -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}

View File

@ -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>

View File

@ -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">

View File

@ -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>

View File

@ -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)}
<div class="flex gap-2 items-center">
<Checkbox <Checkbox
size="tiny"
id="{option.value}-checkbox" id="{option.value}-checkbox"
label={option.text}
checked={value.includes(option.value)} checked={value.includes(option.value)}
{disabled} {disabled}
labelClass="text-gray-500 dark:text-gray-300" onCheckedChange={() => handleCheckboxChange(option.value)}
onchange={() => handleCheckboxChange(option.value)}
/> />
<Label label={option.text} for="{option.value}-checkbox">
{option.text}
</Label>
</div>
{/each} {/each}
</div> </div>
</div> </div>

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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}

View File

@ -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 {

View 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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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,
}; };

View File

@ -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}

View File

@ -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}

View File

@ -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">

View File

@ -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 }}>

View File

@ -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

View File

@ -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

View File

@ -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}

View File

@ -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

View File

@ -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}

View File

@ -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>