Merge branch 'main' of https://github.com/immich-app/immich into chore/admin-only-library

This commit is contained in:
Jonathan Jogenfors
2024-02-22 20:42:38 +01:00
99 changed files with 1269 additions and 925 deletions
+6
View File
@@ -128,3 +128,9 @@ If you feel like this is the right cause and the app is something you are seeing
<a href="https://github.com/alextran1502/immich/graphs/contributors">
<img src="https://contrib.rocks/image?repo=immich-app/immich" width="100%"/>
</a>
## Star History
<a href="https://star-history.com/#immich-app/immich">
<img src="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" alt="Star History Chart" width="100%" />
</a>
+1
View File
@@ -4,6 +4,7 @@ name: immich-e2e
x-server-build: &server-common
image: immich-server:latest
container_name: immich-e2e-server
build:
context: ../
dockerfile: server/Dockerfile
@@ -1,11 +1,15 @@
import { AlbumResponseDto, LoginResponseDto } from '@app/domain';
import { AlbumController } from '@app/immich';
import { AssetFileUploadResponseDto } from '@app/immich/api-v1/asset/response-dto/asset-file-upload-response.dto';
import { SharedLinkType } from '@app/infra/entities';
import { errorStub, userDto, uuidStub } from '@test/fixtures';
import {
AlbumResponseDto,
AssetResponseDto,
LoginResponseDto,
SharedLinkType,
deleteUser,
} from '@immich/sdk';
import { createUserDto, uuidDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { apiUtils, app, asBearerAuth, dbUtils } from 'src/utils';
import request from 'supertest';
import { api } from '../../client';
import { testApp } from '../utils';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
const user1SharedUser = 'user1SharedUser';
const user1SharedLink = 'user1SharedLink';
@@ -14,193 +18,327 @@ const user2SharedUser = 'user2SharedUser';
const user2SharedLink = 'user2SharedLink';
const user2NotShared = 'user2NotShared';
describe(`${AlbumController.name} (e2e)`, () => {
let server: any;
describe('/album', () => {
let admin: LoginResponseDto;
let user1: LoginResponseDto;
let user1Asset: AssetFileUploadResponseDto;
let user1Asset1: AssetResponseDto;
let user1Asset2: AssetResponseDto;
let user1Albums: AlbumResponseDto[];
let user2: LoginResponseDto;
let user2Albums: AlbumResponseDto[];
let user3: LoginResponseDto; // deleted
beforeAll(async () => {
server = (await testApp.create()).getHttpServer();
});
apiUtils.setup();
await dbUtils.reset();
afterAll(async () => {
await testApp.teardown();
});
admin = await apiUtils.adminSetup();
beforeEach(async () => {
await testApp.reset();
await api.authApi.adminSignUp(server);
admin = await api.authApi.adminLogin(server);
await Promise.all([
api.userApi.create(server, admin.accessToken, userDto.user1),
api.userApi.create(server, admin.accessToken, userDto.user2),
[user1, user2, user3] = await Promise.all([
apiUtils.userSetup(admin.accessToken, createUserDto.user1),
apiUtils.userSetup(admin.accessToken, createUserDto.user2),
apiUtils.userSetup(admin.accessToken, createUserDto.user3),
]);
[user1, user2] = await Promise.all([
api.authApi.login(server, userDto.user1),
api.authApi.login(server, userDto.user2),
[user1Asset1, user1Asset2] = await Promise.all([
apiUtils.createAsset(user1.accessToken),
apiUtils.createAsset(user1.accessToken),
]);
user1Asset = await api.assetApi.upload(server, user1.accessToken, 'example');
const albums = await Promise.all([
// user 1
api.albumApi.create(server, user1.accessToken, {
apiUtils.createAlbum(user1.accessToken, {
albumName: user1SharedUser,
sharedWithUserIds: [user2.userId],
assetIds: [user1Asset.id],
assetIds: [user1Asset1.id],
}),
apiUtils.createAlbum(user1.accessToken, {
albumName: user1SharedLink,
assetIds: [user1Asset1.id],
}),
apiUtils.createAlbum(user1.accessToken, {
albumName: user1NotShared,
assetIds: [user1Asset1.id, user1Asset2.id],
}),
api.albumApi.create(server, user1.accessToken, { albumName: user1SharedLink, assetIds: [user1Asset.id] }),
api.albumApi.create(server, user1.accessToken, { albumName: user1NotShared, assetIds: [user1Asset.id] }),
// user 2
api.albumApi.create(server, user2.accessToken, {
apiUtils.createAlbum(user2.accessToken, {
albumName: user2SharedUser,
sharedWithUserIds: [user1.userId],
assetIds: [user1Asset.id],
assetIds: [user1Asset1.id],
}),
apiUtils.createAlbum(user2.accessToken, { albumName: user2SharedLink }),
apiUtils.createAlbum(user2.accessToken, { albumName: user2NotShared }),
// user 3
apiUtils.createAlbum(user3.accessToken, {
albumName: 'Deleted',
sharedWithUserIds: [user1.userId],
}),
api.albumApi.create(server, user2.accessToken, { albumName: user2SharedLink }),
api.albumApi.create(server, user2.accessToken, { albumName: user2NotShared }),
]);
user1Albums = albums.slice(0, 3);
user2Albums = albums.slice(3);
user2Albums = albums.slice(3, 6);
await Promise.all([
// add shared link to user1SharedLink album
api.sharedLinkApi.create(server, user1.accessToken, {
type: SharedLinkType.ALBUM,
apiUtils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Album,
albumId: user1Albums[1].id,
}),
// add shared link to user2SharedLink album
api.sharedLinkApi.create(server, user2.accessToken, {
type: SharedLinkType.ALBUM,
apiUtils.createSharedLink(user2.accessToken, {
type: SharedLinkType.Album,
albumId: user2Albums[1].id,
}),
]);
await deleteUser(
{ id: user3.userId },
{ headers: asBearerAuth(admin.accessToken) }
);
});
describe('GET /album', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get('/album');
const { status, body } = await request(app).get('/album');
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
expect(body).toEqual(errorDto.unauthorized);
});
it('should reject an invalid shared param', async () => {
const { status, body } = await request(server)
const { status, body } = await request(app)
.get('/album?shared=invalid')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toEqual(400);
expect(body).toEqual(errorStub.badRequest(['shared must be a boolean value']));
expect(body).toEqual(
errorDto.badRequest(['shared must be a boolean value'])
);
});
it('should reject an invalid assetId param', async () => {
const { status, body } = await request(server)
const { status, body } = await request(app)
.get('/album?assetId=invalid')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toEqual(400);
expect(body).toEqual(errorStub.badRequest(['assetId must be a UUID']));
expect(body).toEqual(errorDto.badRequest(['assetId must be a UUID']));
});
it('should not return shared albums with a deleted owner', async () => {
await api.userApi.delete(server, admin.accessToken, user1.userId);
const { status, body } = await request(server)
const { status, body } = await request(app)
.get('/album?shared=true')
.set('Authorization', `Bearer ${user2.accessToken}`);
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(1);
expect(body).toHaveLength(3);
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({ ownerId: user2.userId, albumName: user2SharedLink, shared: true }),
]),
expect.objectContaining({
ownerId: user1.userId,
albumName: user1SharedLink,
shared: true,
}),
expect.objectContaining({
ownerId: user1.userId,
albumName: user1SharedUser,
shared: true,
}),
expect.objectContaining({
ownerId: user2.userId,
albumName: user2SharedUser,
shared: true,
}),
])
);
});
it('should return the album collection including owned and shared', async () => {
const { status, body } = await request(server).get('/album').set('Authorization', `Bearer ${user1.accessToken}`);
const { status, body } = await request(app)
.get('/album')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(3);
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({ ownerId: user1.userId, albumName: user1SharedUser, shared: true }),
expect.objectContaining({ ownerId: user1.userId, albumName: user1SharedLink, shared: true }),
expect.objectContaining({ ownerId: user1.userId, albumName: user1NotShared, shared: false }),
]),
expect.objectContaining({
ownerId: user1.userId,
albumName: user1SharedUser,
shared: true,
}),
expect.objectContaining({
ownerId: user1.userId,
albumName: user1SharedLink,
shared: true,
}),
expect.objectContaining({
ownerId: user1.userId,
albumName: user1NotShared,
shared: false,
}),
])
);
});
it('should return the album collection filtered by shared', async () => {
const { status, body } = await request(server)
const { status, body } = await request(app)
.get('/album?shared=true')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(3);
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({ ownerId: user1.userId, albumName: user1SharedUser, shared: true }),
expect.objectContaining({ ownerId: user1.userId, albumName: user1SharedLink, shared: true }),
expect.objectContaining({ ownerId: user2.userId, albumName: user2SharedUser, shared: true }),
]),
expect.objectContaining({
ownerId: user1.userId,
albumName: user1SharedUser,
shared: true,
}),
expect.objectContaining({
ownerId: user1.userId,
albumName: user1SharedLink,
shared: true,
}),
expect.objectContaining({
ownerId: user2.userId,
albumName: user2SharedUser,
shared: true,
}),
])
);
});
it('should return the album collection filtered by NOT shared', async () => {
const { status, body } = await request(server)
const { status, body } = await request(app)
.get('/album?shared=false')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(1);
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({ ownerId: user1.userId, albumName: user1NotShared, shared: false }),
]),
expect.objectContaining({
ownerId: user1.userId,
albumName: user1NotShared,
shared: false,
}),
])
);
});
it('should return the album collection filtered by assetId', async () => {
const asset = await api.assetApi.upload(server, user1.accessToken, 'example2');
await api.albumApi.addAssets(server, user1.accessToken, user1Albums[0].id, { ids: [asset.id] });
const { status, body } = await request(server)
.get(`/album?assetId=${asset.id}`)
const { status, body } = await request(app)
.get(`/album?assetId=${user1Asset2.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(1);
});
it('should return the album collection filtered by assetId and ignores shared=true', async () => {
const { status, body } = await request(server)
.get(`/album?shared=true&assetId=${user1Asset.id}`)
const { status, body } = await request(app)
.get(`/album?shared=true&assetId=${user1Asset1.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(4);
});
it('should return the album collection filtered by assetId and ignores shared=false', async () => {
const { status, body } = await request(server)
.get(`/album?shared=false&assetId=${user1Asset.id}`)
const { status, body } = await request(app)
.get(`/album?shared=false&assetId=${user1Asset1.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(4);
});
});
describe('GET /album/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get(
`/album/${user1Albums[0].id}`
);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should return album info for own album', async () => {
const { status, body } = await request(app)
.get(`/album/${user1Albums[0].id}?withoutAssets=false`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
...user1Albums[0],
assets: [expect.objectContaining(user1Albums[0].assets[0])],
});
});
it('should return album info for shared album', async () => {
const { status, body } = await request(app)
.get(`/album/${user2Albums[0].id}?withoutAssets=false`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
...user2Albums[0],
assets: [expect.objectContaining(user2Albums[0].assets[0])],
});
});
it('should return album info with assets when withoutAssets is undefined', async () => {
const { status, body } = await request(app)
.get(`/album/${user1Albums[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
...user1Albums[0],
assets: [expect.objectContaining(user1Albums[0].assets[0])],
});
});
it('should return album info without assets when withoutAssets is true', async () => {
const { status, body } = await request(app)
.get(`/album/${user1Albums[0].id}?withoutAssets=true`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
...user1Albums[0],
assets: [],
assetCount: 1,
});
});
});
describe('GET /album/count', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/album/count');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should return total count of albums the user has access to', async () => {
const { status, body } = await request(app)
.get('/album/count')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({ owned: 3, shared: 3, notShared: 1 });
});
});
describe('POST /album', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).post('/album').send({ albumName: 'New album' });
const { status, body } = await request(app)
.post('/album')
.send({ albumName: 'New album' });
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
expect(body).toEqual(errorDto.unauthorized);
});
it('should create an album', async () => {
const body = await api.albumApi.create(server, user1.accessToken, { albumName: 'New album' });
const { status, body } = await request(app)
.post('/album')
.send({ albumName: 'New album' })
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(201);
expect(body).toEqual({
id: expect.any(String),
createdAt: expect.any(String),
@@ -220,113 +358,56 @@ describe(`${AlbumController.name} (e2e)`, () => {
});
});
describe('GET /album/count', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get('/album/count');
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should return total count of albums the user has access to', async () => {
const { status, body } = await request(server)
.get('/album/count')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({ owned: 3, shared: 3, notShared: 1 });
});
});
describe('GET /album/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get(`/album/${user1Albums[0].id}`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should return album info for own album', async () => {
const { status, body } = await request(server)
.get(`/album/${user1Albums[0].id}?withoutAssets=false`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({ ...user1Albums[0], assets: [expect.objectContaining(user1Albums[0].assets[0])] });
});
it('should return album info for shared album', async () => {
const { status, body } = await request(server)
.get(`/album/${user2Albums[0].id}?withoutAssets=false`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({ ...user2Albums[0], assets: [expect.objectContaining(user2Albums[0].assets[0])] });
});
it('should return album info with assets when withoutAssets is undefined', async () => {
const { status, body } = await request(server)
.get(`/album/${user1Albums[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({ ...user1Albums[0], assets: [expect.objectContaining(user1Albums[0].assets[0])] });
});
it('should return album info without assets when withoutAssets is true', async () => {
const { status, body } = await request(server)
.get(`/album/${user1Albums[0].id}?withoutAssets=true`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
...user1Albums[0],
assets: [],
assetCount: 1,
});
});
});
describe('PUT /album/:id/assets', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).put(`/album/${user1Albums[0].id}/assets`);
const { status, body } = await request(app).put(
`/album/${user1Albums[0].id}/assets`
);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
expect(body).toEqual(errorDto.unauthorized);
});
it('should be able to add own asset to own album', async () => {
const asset = await api.assetApi.upload(server, user1.accessToken, 'example1');
const { status, body } = await request(server)
const asset = await apiUtils.createAsset(user1.accessToken);
const { status, body } = await request(app)
.put(`/album/${user1Albums[0].id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [asset.id] });
expect(status).toBe(200);
expect(body).toEqual([expect.objectContaining({ id: asset.id, success: true })]);
expect(body).toEqual([
expect.objectContaining({ id: asset.id, success: true }),
]);
});
it('should be able to add own asset to shared album', async () => {
const asset = await api.assetApi.upload(server, user1.accessToken, 'example1');
const { status, body } = await request(server)
const asset = await apiUtils.createAsset(user1.accessToken);
const { status, body } = await request(app)
.put(`/album/${user2Albums[0].id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [asset.id] });
expect(status).toBe(200);
expect(body).toEqual([expect.objectContaining({ id: asset.id, success: true })]);
expect(body).toEqual([
expect.objectContaining({ id: asset.id, success: true }),
]);
});
});
describe('PATCH /album/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(server)
.patch(`/album/${uuidStub.notFound}`)
const { status, body } = await request(app)
.patch(`/album/${uuidDto.notFound}`)
.send({ albumName: 'New album name' });
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
expect(body).toEqual(errorDto.unauthorized);
});
it('should update an album', async () => {
const album = await api.albumApi.create(server, user1.accessToken, { albumName: 'New album' });
const { status, body } = await request(server)
const album = await apiUtils.createAlbum(user1.accessToken, {
albumName: 'New album',
});
const { status, body } = await request(app)
.patch(`/album/${album.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
@@ -345,52 +426,68 @@ describe(`${AlbumController.name} (e2e)`, () => {
describe('DELETE /album/:id/assets', () => {
it('should require authentication', async () => {
const { status, body } = await request(server)
const { status, body } = await request(app)
.delete(`/album/${user1Albums[0].id}/assets`)
.send({ ids: [user1Asset.id] });
.send({ ids: [user1Asset1.id] });
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should be able to remove own asset from own album', async () => {
const { status, body } = await request(server)
.delete(`/album/${user1Albums[0].id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [user1Asset.id] });
expect(status).toBe(200);
expect(body).toEqual([expect.objectContaining({ id: user1Asset.id, success: true })]);
});
it('should be able to remove own asset from shared album', async () => {
const { status, body } = await request(server)
.delete(`/album/${user2Albums[0].id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [user1Asset.id] });
expect(status).toBe(200);
expect(body).toEqual([expect.objectContaining({ id: user1Asset.id, success: true })]);
expect(body).toEqual(errorDto.unauthorized);
});
it('should not be able to remove foreign asset from own album', async () => {
const { status, body } = await request(server)
const { status, body } = await request(app)
.delete(`/album/${user2Albums[0].id}/assets`)
.set('Authorization', `Bearer ${user2.accessToken}`)
.send({ ids: [user1Asset.id] });
.send({ ids: [user1Asset1.id] });
expect(status).toBe(200);
expect(body).toEqual([expect.objectContaining({ id: user1Asset.id, success: false, error: 'no_permission' })]);
expect(body).toEqual([
expect.objectContaining({
id: user1Asset1.id,
success: false,
error: 'no_permission',
}),
]);
});
it('should not be able to remove foreign asset from foreign album', async () => {
const { status, body } = await request(server)
const { status, body } = await request(app)
.delete(`/album/${user1Albums[0].id}/assets`)
.set('Authorization', `Bearer ${user2.accessToken}`)
.send({ ids: [user1Asset.id] });
.send({ ids: [user1Asset1.id] });
expect(status).toBe(200);
expect(body).toEqual([expect.objectContaining({ id: user1Asset.id, success: false, error: 'no_permission' })]);
expect(body).toEqual([
expect.objectContaining({
id: user1Asset1.id,
success: false,
error: 'no_permission',
}),
]);
});
it('should be able to remove own asset from own album', async () => {
const { status, body } = await request(app)
.delete(`/album/${user1Albums[0].id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [user1Asset1.id] });
expect(status).toBe(200);
expect(body).toEqual([
expect.objectContaining({ id: user1Asset1.id, success: true }),
]);
});
it('should be able to remove own asset from shared album', async () => {
const { status, body } = await request(app)
.delete(`/album/${user2Albums[0].id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [user1Asset1.id] });
expect(status).toBe(200);
expect(body).toEqual([
expect.objectContaining({ id: user1Asset1.id, success: true }),
]);
});
});
@@ -398,51 +495,57 @@ describe(`${AlbumController.name} (e2e)`, () => {
let album: AlbumResponseDto;
beforeEach(async () => {
album = await api.albumApi.create(server, user1.accessToken, { albumName: 'testAlbum' });
album = await apiUtils.createAlbum(user1.accessToken, {
albumName: 'testAlbum',
});
});
it('should require authentication', async () => {
const { status, body } = await request(server)
const { status, body } = await request(app)
.put(`/album/${user1Albums[0].id}/users`)
.send({ sharedUserIds: [] });
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
expect(body).toEqual(errorDto.unauthorized);
});
it('should be able to add user to own album', async () => {
const { status, body } = await request(server)
const { status, body } = await request(app)
.put(`/album/${album.id}/users`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ sharedUserIds: [user2.userId] });
expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ sharedUsers: [expect.objectContaining({ id: user2.userId })] }));
expect(body).toEqual(
expect.objectContaining({
sharedUsers: [expect.objectContaining({ id: user2.userId })],
})
);
});
it('should not be able to share album with owner', async () => {
const { status, body } = await request(server)
const { status, body } = await request(app)
.put(`/album/${album.id}/users`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ sharedUserIds: [user1.userId] });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest('Cannot be shared with owner'));
expect(body).toEqual(errorDto.badRequest('Cannot be shared with owner'));
});
it('should not be able to add existing user to shared album', async () => {
await request(server)
await request(app)
.put(`/album/${album.id}/users`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ sharedUserIds: [user2.userId] });
const { status, body } = await request(server)
const { status, body } = await request(app)
.put(`/album/${album.id}/users`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ sharedUserIds: [user2.userId] });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest('User already added'));
expect(body).toEqual(errorDto.badRequest('User already added'));
});
});
});
+51
View File
@@ -0,0 +1,51 @@
import {
deleteAssets,
getAuditFiles,
updateAsset,
type LoginResponseDto,
} from '@immich/sdk';
import { apiUtils, asBearerAuth, dbUtils, fileUtils } from 'src/utils';
import { beforeAll, describe, expect, it } from 'vitest';
describe('/audit', () => {
let admin: LoginResponseDto;
beforeAll(async () => {
apiUtils.setup();
await dbUtils.reset();
await fileUtils.reset();
admin = await apiUtils.adminSetup();
});
describe('GET :/file-report', () => {
it('excludes assets without issues from report', async () => {
const [trashedAsset, archivedAsset, _] = await Promise.all([
apiUtils.createAsset(admin.accessToken),
apiUtils.createAsset(admin.accessToken),
apiUtils.createAsset(admin.accessToken),
]);
await Promise.all([
deleteAssets(
{ assetBulkDeleteDto: { ids: [trashedAsset.id] } },
{ headers: asBearerAuth(admin.accessToken) }
),
updateAsset(
{
id: archivedAsset.id,
updateAssetDto: { isArchived: true },
},
{ headers: asBearerAuth(admin.accessToken) }
),
]);
const body = await getAuditFiles({
headers: asBearerAuth(admin.accessToken),
});
expect(body.orphans).toHaveLength(0);
expect(body.extras).toHaveLength(0);
});
});
});
+2
View File
@@ -56,6 +56,7 @@ describe('/activity', () => {
expect(status).toBe(200);
expect(body).toEqual({
total: 2,
hidden: 1,
people: [
expect.objectContaining({ name: 'visible_person' }),
expect.objectContaining({ name: 'hidden_person' }),
@@ -71,6 +72,7 @@ describe('/activity', () => {
expect(status).toBe(200);
expect(body).toEqual({
total: 2,
hidden: 1,
people: [expect.objectContaining({ name: 'visible_person' })],
});
});
+27 -35
View File
@@ -15,9 +15,6 @@ import { apiUtils, app, asBearerAuth, dbUtils } from 'src/utils';
import request from 'supertest';
import { beforeAll, describe, expect, it } from 'vitest';
const createSharedLink = (dto: SharedLinkCreateDto, accessToken: string) =>
create({ sharedLinkCreateDto: dto }, { headers: asBearerAuth(accessToken) });
describe('/shared-link', () => {
let admin: LoginResponseDto;
let asset1: AssetResponseDto;
@@ -78,38 +75,33 @@ describe('/shared-link', () => {
linkWithMetadata,
linkWithoutMetadata,
] = await Promise.all([
createSharedLink(
{ type: SharedLinkType.Album, albumId: deletedAlbum.id },
user2.accessToken
),
createSharedLink(
{ type: SharedLinkType.Album, albumId: album.id },
user1.accessToken
),
createSharedLink(
{ type: SharedLinkType.Individual, assetIds: [asset1.id] },
user1.accessToken
),
createSharedLink(
{ type: SharedLinkType.Album, albumId: album.id, password: 'foo' },
user1.accessToken
),
createSharedLink(
{
type: SharedLinkType.Album,
albumId: metadataAlbum.id,
showMetadata: true,
},
user1.accessToken
),
createSharedLink(
{
type: SharedLinkType.Album,
albumId: metadataAlbum.id,
showMetadata: false,
},
user1.accessToken
),
apiUtils.createSharedLink(user2.accessToken, {
type: SharedLinkType.Album,
albumId: deletedAlbum.id,
}),
apiUtils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Album,
albumId: album.id,
}),
apiUtils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Individual,
assetIds: [asset1.id],
}),
apiUtils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Album,
albumId: album.id,
password: 'foo',
}),
apiUtils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Album,
albumId: metadataAlbum.id,
showMetadata: true,
}),
apiUtils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Album,
albumId: metadataAlbum.id,
showMetadata: false,
}),
]);
await deleteUser(
+51 -66
View File
@@ -1,26 +1,31 @@
import {
LoginResponseDto,
UserResponseDto,
createUser,
deleteUser,
getUserById,
} from '@immich/sdk';
import { LoginResponseDto, deleteUser, getUserById } from '@immich/sdk';
import { createUserDto, userDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { apiUtils, app, asBearerAuth, dbUtils } from 'src/utils';
import request from 'supertest';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { beforeAll, describe, expect, it } from 'vitest';
describe('/server-info', () => {
let admin: LoginResponseDto;
let deletedUser: LoginResponseDto;
let userToDelete: LoginResponseDto;
let nonAdmin: LoginResponseDto;
beforeAll(async () => {
apiUtils.setup();
});
beforeEach(async () => {
await dbUtils.reset();
admin = await apiUtils.adminSetup({ onboarding: false });
[deletedUser, nonAdmin, userToDelete] = await Promise.all([
apiUtils.userSetup(admin.accessToken, createUserDto.user1),
apiUtils.userSetup(admin.accessToken, createUserDto.user2),
apiUtils.userSetup(admin.accessToken, createUserDto.user3),
]);
await deleteUser(
{ id: deletedUser.userId },
{ headers: asBearerAuth(admin.accessToken) }
);
});
describe('GET /user', () => {
@@ -30,60 +35,54 @@ describe('/server-info', () => {
expect(body).toEqual(errorDto.unauthorized);
});
it('should start with the admin', async () => {
it('should get users', async () => {
const { status, body } = await request(app)
.get('/user')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(200);
expect(body).toHaveLength(1);
expect(body[0]).toMatchObject({ email: 'admin@immich.cloud' });
expect(body).toHaveLength(4);
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({ email: 'admin@immich.cloud' }),
expect.objectContaining({ email: 'user1@immich.cloud' }),
expect.objectContaining({ email: 'user2@immich.cloud' }),
expect.objectContaining({ email: 'user3@immich.cloud' }),
])
);
});
it('should hide deleted users', async () => {
const user1 = await apiUtils.userSetup(
admin.accessToken,
createUserDto.user1
);
await deleteUser(
{ id: user1.userId },
{ headers: asBearerAuth(admin.accessToken) }
);
const { status, body } = await request(app)
.get(`/user`)
.query({ isAll: true })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(1);
expect(body[0]).toMatchObject({ email: 'admin@immich.cloud' });
expect(body).toHaveLength(3);
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({ email: 'admin@immich.cloud' }),
expect.objectContaining({ email: 'user2@immich.cloud' }),
expect.objectContaining({ email: 'user3@immich.cloud' }),
])
);
});
it('should include deleted users', async () => {
const user1 = await apiUtils.userSetup(
admin.accessToken,
createUserDto.user1
);
await deleteUser(
{ id: user1.userId },
{ headers: asBearerAuth(admin.accessToken) }
);
const { status, body } = await request(app)
.get(`/user`)
.query({ isAll: false })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(2);
expect(body[0]).toMatchObject({
id: user1.userId,
email: 'user1@immich.cloud',
deletedAt: expect.any(String),
});
expect(body[1]).toMatchObject({
id: admin.userId,
email: 'admin@immich.cloud',
});
expect(body).toHaveLength(4);
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({ email: 'admin@immich.cloud' }),
expect.objectContaining({ email: 'user1@immich.cloud' }),
expect.objectContaining({ email: 'user2@immich.cloud' }),
expect.objectContaining({ email: 'user3@immich.cloud' }),
])
);
});
});
@@ -149,13 +148,13 @@ describe('/server-info', () => {
.post(`/user`)
.send({
isAdmin: true,
email: 'user1@immich.cloud',
password: 'Password123',
email: 'user4@immich.cloud',
password: 'password123',
name: 'Immich',
})
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toMatchObject({
email: 'user1@immich.cloud',
email: 'user4@immich.cloud',
isAdmin: false,
shouldChangePassword: true,
});
@@ -181,18 +180,9 @@ describe('/server-info', () => {
});
describe('DELETE /user/:id', () => {
let userToDelete: UserResponseDto;
beforeEach(async () => {
userToDelete = await createUser(
{ createUserDto: createUserDto.user1 },
{ headers: asBearerAuth(admin.accessToken) }
);
});
it('should require authentication', async () => {
const { status, body } = await request(app).delete(
`/user/${userToDelete.id}`
`/user/${userToDelete.userId}`
);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
@@ -200,12 +190,12 @@ describe('/server-info', () => {
it('should delete user', async () => {
const { status, body } = await request(app)
.delete(`/user/${userToDelete.id}`)
.delete(`/user/${userToDelete.userId}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
...userToDelete,
expect(body).toMatchObject({
id: userToDelete.userId,
updatedAt: expect.any(String),
deletedAt: expect.any(String),
});
@@ -231,14 +221,9 @@ describe('/server-info', () => {
}
it('should not allow a non-admin to become an admin', async () => {
const user = await apiUtils.userSetup(
admin.accessToken,
createUserDto.user1
);
const { status, body } = await request(app)
.put(`/user`)
.send({ isAdmin: true, id: user.userId })
.send({ isAdmin: true, id: nonAdmin.userId })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
+29 -1
View File
@@ -1,10 +1,14 @@
import {
AssetResponseDto,
CreateAlbumDto,
CreateAssetDto,
CreateUserDto,
PersonUpdateDto,
SharedLinkCreateDto,
createAlbum,
createApiKey,
createPerson,
createSharedLink,
createUser,
defaults,
login,
@@ -13,14 +17,17 @@ import {
updatePerson,
} from '@immich/sdk';
import { BrowserContext } from '@playwright/test';
import { spawn } from 'child_process';
import { exec, spawn } from 'child_process';
import { randomBytes } from 'node:crypto';
import { access } from 'node:fs/promises';
import path from 'node:path';
import { promisify } from 'node:util';
import pg from 'pg';
import { loginDto, signupDto } from 'src/fixtures';
import request from 'supertest';
const execPromise = promisify(exec);
export const app = 'http://127.0.0.1:2283/api';
const directoryExists = (directory: string) =>
@@ -31,6 +38,9 @@ const directoryExists = (directory: string) =>
// TODO move test assets into e2e/assets
export const testAssetDir = path.resolve(`./../server/test/assets/`);
const serverContainerName = 'immich-e2e-server';
const uploadMediaDir = '/usr/src/app/upload/upload';
if (!(await directoryExists(`${testAssetDir}/albums`))) {
throw new Error(
`Test assets not found. Please checkout https://github.com/immich-app/test-assets into ${testAssetDir} before testing`
@@ -46,6 +56,14 @@ export const asKeyAuth = (key: string) => ({ 'x-api-key': key });
let client: pg.Client | null = null;
export const fileUtils = {
reset: async () => {
await execPromise(
`docker exec -i "${serverContainerName}" rm -R "${uploadMediaDir}"`
);
},
};
export const dbUtils = {
createFace: async ({
assetId,
@@ -181,6 +199,11 @@ export const apiUtils = {
{ headers: asBearerAuth(accessToken) }
);
},
createAlbum: (accessToken: string, dto: CreateAlbumDto) =>
createAlbum(
{ createAlbumDto: dto },
{ headers: asBearerAuth(accessToken) }
),
createAsset: async (
accessToken: string,
dto?: Omit<CreateAssetDto, 'assetData'>
@@ -211,6 +234,11 @@ export const apiUtils = {
{ headers: asBearerAuth(accessToken) }
);
},
createSharedLink: (accessToken: string, dto: SharedLinkCreateDto) =>
createSharedLink(
{ sharedLinkCreateDto: dto },
{ headers: asBearerAuth(accessToken) }
),
};
export const cliUtils = {
+1
View File
@@ -8,6 +8,7 @@ import 'package:openapi/api.dart';
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**hidden** | **int** | |
**people** | [**List<PersonResponseDto>**](PersonResponseDto.md) | | [default to const []]
**total** | **int** | |
+9 -1
View File
@@ -13,30 +13,36 @@ part of openapi.api;
class PeopleResponseDto {
/// Returns a new [PeopleResponseDto] instance.
PeopleResponseDto({
required this.hidden,
this.people = const [],
required this.total,
});
int hidden;
List<PersonResponseDto> people;
int total;
@override
bool operator ==(Object other) => identical(this, other) || other is PeopleResponseDto &&
other.hidden == hidden &&
_deepEquality.equals(other.people, people) &&
other.total == total;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(hidden.hashCode) +
(people.hashCode) +
(total.hashCode);
@override
String toString() => 'PeopleResponseDto[people=$people, total=$total]';
String toString() => 'PeopleResponseDto[hidden=$hidden, people=$people, total=$total]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'hidden'] = this.hidden;
json[r'people'] = this.people;
json[r'total'] = this.total;
return json;
@@ -50,6 +56,7 @@ class PeopleResponseDto {
final json = value.cast<String, dynamic>();
return PeopleResponseDto(
hidden: mapValueOfType<int>(json, r'hidden')!,
people: PersonResponseDto.listFromJson(json[r'people']),
total: mapValueOfType<int>(json, r'total')!,
);
@@ -99,6 +106,7 @@ class PeopleResponseDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'hidden',
'people',
'total',
};
+5
View File
@@ -16,6 +16,11 @@ void main() {
// final instance = PeopleResponseDto();
group('test PeopleResponseDto', () {
// int hidden
test('to test the property `hidden`', () async {
// TODO
});
// List<PersonResponseDto> people (default value: const [])
test('to test the property `people`', () async {
// TODO
+4
View File
@@ -8633,6 +8633,9 @@
},
"PeopleResponseDto": {
"properties": {
"hidden": {
"type": "integer"
},
"people": {
"items": {
"$ref": "#/components/schemas/PersonResponseDto"
@@ -8644,6 +8647,7 @@
}
},
"required": [
"hidden",
"people",
"total"
],
+6
View File
@@ -2795,6 +2795,12 @@ export type PathType = typeof PathType[keyof typeof PathType];
* @interface PeopleResponseDto
*/
export interface PeopleResponseDto {
/**
*
* @type {number}
* @memberof PeopleResponseDto
*/
'hidden': number;
/**
*
* @type {Array<PersonResponseDto>}
+1
View File
@@ -526,6 +526,7 @@ export type UpdatePartnerDto = {
inTimeline: boolean;
};
export type PeopleResponseDto = {
hidden: number;
people: PersonResponseDto[];
total: number;
};
+2 -2
View File
@@ -1,5 +1,5 @@
# dev build
FROM ghcr.io/immich-app/base-server-dev:20240213@sha256:16646a37bae065b51e68cb2ba7a63027b29504d43a30644625382afbe326114a as dev
FROM ghcr.io/immich-app/base-server-dev:20240222@sha256:2ff467d6ae5c00a2317eb7b13cb40ba5be0fd33c160175dba621b1bf72bc1cd1 as dev
RUN apt-get install --no-install-recommends -yqq tini
WORKDIR /usr/src/app
@@ -40,7 +40,7 @@ RUN npm run build
# prod build
FROM ghcr.io/immich-app/base-server-prod:20240213@sha256:61d159d069c5b522f16de9733fb79feb0e82c0b099d16f026196f344d12a1e5e
FROM ghcr.io/immich-app/base-server-prod:20240222@sha256:9ae5eebf95cf7759eec9dcfbd9e48a722701075ac855209f2e0b01c631b76f5c
WORKDIR /usr/src/app
ENV NODE_ENV=production \
+3 -3
View File
@@ -531,8 +531,8 @@ describe(`${AssetController.name} (e2e)`, () => {
expect(status).toBe(200);
expect(body.length).toBe(assets.length);
for (let i = 0; i < assets.length; i++) {
expect(body[i]).toEqual(expect.objectContaining({ id: assets[i].id }));
for (const [i, asset] of assets.entries()) {
expect(body[i]).toEqual(expect.objectContaining({ id: asset.id }));
}
});
}
@@ -699,7 +699,7 @@ describe(`${AssetController.name} (e2e)`, () => {
it("should not upload to another user's library", async () => {
const content = randomBytes(32);
const library = (await api.libraryApi.getAll(server, user2.accessToken))[0];
const [library] = await api.libraryApi.getAll(server, user2.accessToken);
await api.assetApi.upload(server, user1.accessToken, 'example-image', { content });
const { body, status } = await request(server)
+3 -3
View File
@@ -44,7 +44,7 @@ describe(`${SearchController.name}`, () => {
describe('GET /search (exif)', () => {
beforeEach(async () => {
const assetId = (await assetRepository.create(generateAsset(loginResponse.userId, libraries))).id;
const { id: assetId } = await assetRepository.create(generateAsset(loginResponse.userId, libraries));
await assetRepository.upsertExif({ assetId, ...searchStub.exif });
const assetWithMetadata = await assetRepository.getById(assetId, { exifInfo: true });
@@ -166,7 +166,7 @@ describe(`${SearchController.name}`, () => {
describe('GET /search (smart info)', () => {
beforeEach(async () => {
const assetId = (await assetRepository.create(generateAsset(loginResponse.userId, libraries))).id;
const { id: assetId } = await assetRepository.create(generateAsset(loginResponse.userId, libraries));
await assetRepository.upsertExif({ assetId, ...searchStub.exif });
await smartInfoRepository.upsert({ assetId, ...searchStub.smartInfo }, Array.from({ length: 512 }, Math.random));
@@ -215,7 +215,7 @@ describe(`${SearchController.name}`, () => {
describe('GET /search (file name)', () => {
beforeEach(async () => {
const assetId = (await assetRepository.create(generateAsset(loginResponse.userId, libraries))).id;
const { id: assetId } = await assetRepository.create(generateAsset(loginResponse.userId, libraries));
await assetRepository.upsertExif({ assetId, ...searchStub.exif });
const assetWithMetadata = await assetRepository.getById(assetId, { exifInfo: true });
-14
View File
@@ -1,14 +0,0 @@
import { ActivityCreateDto, ActivityResponseDto } from '@app/domain';
import request from 'supertest';
export const activityApi = {
create: async (server: any, accessToken: string, dto: ActivityCreateDto) => {
const res = await request(server).post('/activity').set('Authorization', `Bearer ${accessToken}`).send(dto);
expect(res.status === 200 || res.status === 201).toBe(true);
return res.body as ActivityResponseDto;
},
delete: async (server: any, accessToken: string, id: string) => {
const res = await request(server).delete(`/activity/${id}`).set('Authorization', `Bearer ${accessToken}`);
expect(res.status).toEqual(204);
},
};
-28
View File
@@ -1,28 +0,0 @@
import { AddUsersDto, AlbumResponseDto, BulkIdResponseDto, BulkIdsDto, CreateAlbumDto } from '@app/domain';
import request from 'supertest';
export const albumApi = {
create: async (server: any, accessToken: string, dto: CreateAlbumDto) => {
const res = await request(server).post('/album').set('Authorization', `Bearer ${accessToken}`).send(dto);
expect(res.status).toEqual(201);
return res.body as AlbumResponseDto;
},
addAssets: async (server: any, accessToken: string, id: string, dto: BulkIdsDto) => {
const res = await request(server)
.put(`/album/${id}/assets`)
.set('Authorization', `Bearer ${accessToken}`)
.send(dto);
expect(res.status).toEqual(200);
return res.body as BulkIdResponseDto[];
},
addUsers: async (server: any, accessToken: string, id: string, dto: AddUsersDto) => {
const res = await request(server).put(`/album/${id}/users`).set('Authorization', `Bearer ${accessToken}`).send(dto);
expect(res.status).toEqual(200);
return res.body as AlbumResponseDto;
},
getAllAlbums: async (server: any, accessToken: string) => {
const res = await request(server).get(`/album/`).set('Authorization', `Bearer ${accessToken}`).send();
expect(res.status).toEqual(200);
return res.body as AlbumResponseDto[];
},
};
-16
View File
@@ -1,16 +0,0 @@
import { APIKeyCreateResponseDto } from '@app/domain';
import { apiKeyCreateStub } from '@test';
import request from 'supertest';
export const apiKeyApi = {
createApiKey: async (server: any, accessToken: string) => {
const { status, body } = await request(server)
.post('/api-key')
.set('Authorization', `Bearer ${accessToken}`)
.send(apiKeyCreateStub);
expect(status).toBe(201);
return body as APIKeyCreateResponseDto;
},
};
+1 -16
View File
@@ -1,4 +1,4 @@
import { AuthDeviceResponseDto, LoginCredentialDto, LoginResponseDto, UserResponseDto } from '@app/domain';
import { LoginCredentialDto, LoginResponseDto, UserResponseDto } from '@app/domain';
import { adminSignupStub, loginResponseStub, loginStub } from '@test';
import request from 'supertest';
@@ -27,19 +27,4 @@ export const authApi = {
return body as LoginResponseDto;
},
getAuthDevices: async (server: any, accessToken: string) => {
const { status, body } = await request(server).get('/auth/devices').set('Authorization', `Bearer ${accessToken}`);
expect(body).toEqual(expect.any(Array));
expect(status).toBe(200);
return body as AuthDeviceResponseDto[];
},
validateToken: async (server: any, accessToken: string) => {
const { status, body } = await request(server)
.post('/auth/validateToken')
.set('Authorization', `Bearer ${accessToken}`);
expect(body).toEqual({ authStatus: true });
expect(status).toBe(200);
},
};
-10
View File
@@ -1,25 +1,15 @@
import { activityApi } from './activity-api';
import { albumApi } from './album-api';
import { apiKeyApi } from './api-key-api';
import { assetApi } from './asset-api';
import { authApi } from './auth-api';
import { libraryApi } from './library-api';
import { partnerApi } from './partner-api';
import { serverInfoApi } from './server-info-api';
import { sharedLinkApi } from './shared-link-api';
import { trashApi } from './trash-api';
import { userApi } from './user-api';
export const api = {
activityApi,
authApi,
apiKeyApi,
assetApi,
libraryApi,
serverInfoApi,
sharedLinkApi,
trashApi,
albumApi,
userApi,
partnerApi,
};
-10
View File
@@ -1,10 +0,0 @@
import { PartnerResponseDto } from '@app/domain';
import request from 'supertest';
export const partnerApi = {
create: async (server: any, accessToken: string, id: string) => {
const { status, body } = await request(server).post(`/partner/${id}`).set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(201);
return body as PartnerResponseDto;
},
};
-10
View File
@@ -1,10 +0,0 @@
import { ServerConfigDto } from '@app/domain';
import request from 'supertest';
export const serverInfoApi = {
getConfig: async (server: any) => {
const res = await request(server).get('/server-info/config');
expect(res.status).toBe(200);
return res.body as ServerConfigDto;
},
};
-7
View File
@@ -10,11 +10,4 @@ export const sharedLinkApi = {
expect(status).toBe(201);
return body as SharedLinkResponseDto;
},
getMySharedLink: async (server: any, key: string) => {
const { status, body } = await request(server).get('/shared-link/me').query({ key });
expect(status).toBe(200);
return body as SharedLinkResponseDto;
},
};
-10
View File
@@ -18,16 +18,6 @@ export const userApi = {
return body as UserResponseDto;
},
get: async (server: any, accessToken: string, id: string) => {
const { status, body } = await request(server)
.get(`/user/info/${id}`)
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({ id });
return body as UserResponseDto;
},
update: async (server: any, accessToken: string, dto: UpdateUserDto) => {
const { status, body } = await request(server).put('/user').set('Authorization', `Bearer ${accessToken}`).send(dto);
+8 -8
View File
@@ -1,7 +1,7 @@
import { LoginResponseDto } from '@app/domain';
import { AssetType } from '@app/infra/entities';
import { readFile } from 'fs/promises';
import { basename, join } from 'path';
import { readFile } from 'node:fs/promises';
import { basename, join } from 'node:path';
import { IMMICH_TEST_ASSET_PATH, testApp } from '../../../src/test-utils/utils';
import { api } from '../../client';
@@ -19,7 +19,7 @@ const JPEG = {
iso: 200,
fNumber: 11,
exposureTime: '1/160',
fileSizeInByte: 53493,
fileSizeInByte: 53_493,
make: 'SONY',
model: 'DSLR-A550',
orientation: null,
@@ -42,11 +42,11 @@ const tests = [
exifImageWidth: 4032,
exifImageHeight: 3024,
latitude: 41.2203,
longitude: -96.071625,
longitude: -96.071_625,
make: 'Apple',
model: 'iPhone 7',
lensModel: 'iPhone 7 back camera 3.99mm f/1.8',
fileSizeInByte: 880703,
fileSizeInByte: 880_703,
exposureTime: '1/887',
iso: 20,
focalLength: 3.99,
@@ -66,7 +66,7 @@ const tests = [
exifImageHeight: 800,
latitude: null,
longitude: null,
fileSizeInByte: 25408,
fileSizeInByte: 25_408,
},
},
},
@@ -84,7 +84,7 @@ const tests = [
fNumber: 10,
focalLength: 18,
iso: 100,
fileSizeInByte: 9057784,
fileSizeInByte: 9_057_784,
dateTimeOriginal: '2010-07-20T17:27:12.000Z',
latitude: null,
longitude: null,
@@ -106,7 +106,7 @@ const tests = [
fNumber: 11,
focalLength: 85,
iso: 200,
fileSizeInByte: 15856335,
fileSizeInByte: 15_856_335,
dateTimeOriginal: '2016-09-22T22:10:29.060Z',
latitude: null,
longitude: null,
@@ -1,7 +1,7 @@
import { LibraryResponseDto, LibraryService, LoginResponseDto } from '@app/domain';
import { AssetType, LibraryType } from '@app/infra/entities';
import fs from 'fs/promises';
import path from 'path';
import fs from 'node:fs/promises';
import path from 'node:path';
import {
IMMICH_TEST_ASSET_PATH,
IMMICH_TEST_ASSET_TEMP_PATH,
@@ -20,7 +20,8 @@ describe(`Library watcher (e2e)`, () => {
beforeAll(async () => {
process.env.IMMICH_CONFIG_FILE = path.normalize(`${__dirname}/../config/library-watcher-e2e-config.json`);
server = (await testApp.create()).getHttpServer();
const app = await testApp.create();
server = app.getHttpServer();
libraryService = testApp.get(LibraryService);
});
+1 -1
View File
@@ -326,7 +326,7 @@ export class AssetService {
const stackIdsToCheckForDelete: string[] = [];
if (removeParent) {
(options as Partial<AssetEntity>).stack = null;
const assets = await this.assetRepository.getByIds(ids);
const assets = await this.assetRepository.getByIds(ids, { stack: true });
stackIdsToCheckForDelete.push(...new Set(assets.filter((a) => !!a.stackId).map((a) => a.stackId!)));
// This updates the updatedAt column of the parents to indicate that one of its children is removed
// All the unique parent's -> parent is set to null
@@ -73,23 +73,21 @@ const peopleWithFaces = (faces: AssetFaceEntity[]): PersonWithFacesResponseDto[]
export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): AssetResponseDto {
const { stripMetadata = false, withStack = false } = options;
const sanitizedAssetResponse: SanitizedAssetResponseDto = {
id: entity.id,
type: entity.type,
thumbhash: entity.thumbhash?.toString('base64') ?? null,
localDateTime: entity.localDateTime,
resized: !!entity.resizePath,
duration: entity.duration ?? '0:00:00.00000',
livePhotoVideoId: entity.livePhotoVideoId,
hasMetadata: false,
};
if (stripMetadata) {
const sanitizedAssetResponse: SanitizedAssetResponseDto = {
id: entity.id,
type: entity.type,
thumbhash: entity.thumbhash?.toString('base64') ?? null,
localDateTime: entity.localDateTime,
resized: !!entity.resizePath,
duration: entity.duration ?? '0:00:00.00000',
livePhotoVideoId: entity.livePhotoVideoId,
hasMetadata: false,
};
return sanitizedAssetResponse as AssetResponseDto;
}
return {
...sanitizedAssetResponse,
id: entity.id,
deviceAssetId: entity.deviceAssetId,
ownerId: entity.ownerId,
+1 -1
View File
@@ -167,7 +167,7 @@ export class AuditService {
`Found ${libraryFiles.size} original files, ${thumbFiles.size} thumbnails, ${videoFiles.size} encoded videos, ${profileFiles.size} profile files`,
);
const pagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (options) =>
this.assetRepository.getAll(options, { withDeleted: true }),
this.assetRepository.getAll(options, { withDeleted: true, withArchived: true }),
);
let assetCount = 0;
@@ -72,7 +72,7 @@ export class DatabaseService {
In this case, please run 'CREATE EXTENSION IF NOT EXISTS ${this.vectorExt}' manually as a superuser.
See https://immich.app/docs/guides/database-queries for how to query the database.
Alternatively, if your Postgres instance has ${extName[otherExt]}, you may use this instead by setting the environment variable 'DB_VECTOR_EXTENSION=${otherExt}'.
Alternatively, if your Postgres instance has ${extName[otherExt]}, you may use this instead by setting the environment variable 'DB_VECTOR_EXTENSION=${extName[otherExt]}'.
Note that switching between the two extensions after a successful startup is not supported.
The exception is if your version of Immich prior to upgrading was 1.90.2 or earlier.
In this case, you may set either extension now, but you will not be able to switch to the other extension following a successful startup.
+2 -1
View File
@@ -127,7 +127,8 @@ export class PersonStatisticsResponseDto {
export class PeopleResponseDto {
@ApiProperty({ type: 'integer' })
total!: number;
@ApiProperty({ type: 'integer' })
hidden!: number;
people!: PersonResponseDto[];
}
@@ -114,35 +114,12 @@ describe(PersonService.name, () => {
});
describe('getAll', () => {
it('should get all people with thumbnails', async () => {
personMock.getAllForUser.mockResolvedValue([personStub.withName, personStub.noThumbnail]);
personMock.getNumberOfPeople.mockResolvedValue(1);
await expect(sut.getAll(authStub.admin, { withHidden: undefined })).resolves.toEqual({
total: 1,
people: [responseDto],
});
expect(personMock.getAllForUser).toHaveBeenCalledWith(authStub.admin.user.id, {
minimumFaceCount: 3,
withHidden: false,
});
});
it('should get all visible people with thumbnails', async () => {
personMock.getAllForUser.mockResolvedValue([personStub.withName, personStub.hidden]);
personMock.getNumberOfPeople.mockResolvedValue(2);
await expect(sut.getAll(authStub.admin, { withHidden: false })).resolves.toEqual({
total: 2,
people: [responseDto],
});
expect(personMock.getAllForUser).toHaveBeenCalledWith(authStub.admin.user.id, {
minimumFaceCount: 3,
withHidden: false,
});
});
it('should get all hidden and visible people with thumbnails', async () => {
personMock.getAllForUser.mockResolvedValue([personStub.withName, personStub.hidden]);
personMock.getNumberOfPeople.mockResolvedValue(2);
personMock.getNumberOfPeople.mockResolvedValue({ total: 2, hidden: 1 });
await expect(sut.getAll(authStub.admin, { withHidden: true })).resolves.toEqual({
total: 2,
hidden: 1,
people: [
responseDto,
{
+3 -6
View File
@@ -82,15 +82,12 @@ export class PersonService {
minimumFaceCount: machineLearning.facialRecognition.minFaces,
withHidden: dto.withHidden || false,
});
const total = await this.repository.getNumberOfPeople(auth.user.id);
const persons: PersonResponseDto[] = people
// with thumbnails
.filter((person) => !!person.thumbnailPath)
.map((person) => mapPerson(person));
const { total, hidden } = await this.repository.getNumberOfPeople(auth.user.id);
return {
people: persons.filter((person) => dto.withHidden || !person.isHidden),
people: people.map((person) => mapPerson(person)),
total,
hidden,
};
}
@@ -28,6 +28,11 @@ export interface PersonStatistics {
assets: number;
}
export interface PeopleStatistics {
total: number;
hidden: number;
}
export interface IPersonRepository {
getAll(pagination: PaginationOptions, options?: FindManyOptions<PersonEntity>): Paginated<PersonEntity>;
getAllForUser(userId: string, options: PersonSearchOptions): Promise<PersonEntity[]>;
@@ -54,7 +59,7 @@ export interface IPersonRepository {
getRandomFace(personId: string): Promise<AssetFaceEntity | null>;
getStatistics(personId: string): Promise<PersonStatistics>;
reassignFace(assetFaceId: string, newPersonId: string): Promise<number>;
getNumberOfPeople(userId: string): Promise<number>;
getNumberOfPeople(userId: string): Promise<PeopleStatistics>;
reassignFaces(data: UpdateFacesData): Promise<number>;
update(entity: Partial<PersonEntity>): Promise<PersonEntity>;
}
+2 -2
View File
@@ -213,9 +213,9 @@ export function searchAssetBuilder(
if (personIds && personIds.length > 0) {
builder
.leftJoin(`${builder.alias}.faces`, 'faces')
.andWhere('faces.personId IN (:...personIds)', { personIds: personIds })
.andWhere('faces.personId IN (:...personIds)', { personIds })
.addGroupBy(`${builder.alias}.id`)
.having('COUNT(faces.id) = :personCount', { personCount: personIds.length });
.having('COUNT(DISTINCT faces.personId) = :personCount', { personCount: personIds.length });
if (withExif) {
builder.addGroupBy('exifInfo.assetId');
@@ -3,6 +3,7 @@ import {
IPersonRepository,
Paginated,
PaginationOptions,
PeopleStatistics,
PersonNameSearchOptions,
PersonSearchOptions,
PersonStatistics,
@@ -69,6 +70,7 @@ export class PersonRepository implements IPersonRepository {
.addOrderBy("NULLIF(person.name, '') IS NULL", 'ASC')
.addOrderBy('COUNT(face.assetId)', 'DESC')
.addOrderBy("NULLIF(person.name, '')", 'ASC', 'NULLS LAST')
.andWhere("person.thumbnailPath != ''")
.having("person.name != '' OR COUNT(face.assetId) >= :faces", { faces: options?.minimumFaceCount || 1 })
.groupBy('person.id')
.limit(500);
@@ -207,15 +209,25 @@ export class PersonRepository implements IPersonRepository {
}
@GenerateSql({ params: [DummyValue.UUID] })
async getNumberOfPeople(userId: string): Promise<number> {
return this.personRepository
async getNumberOfPeople(userId: string): Promise<PeopleStatistics> {
const items = await this.personRepository
.createQueryBuilder('person')
.leftJoin('person.faces', 'face')
.where('person.ownerId = :userId', { userId })
.innerJoin('face.asset', 'asset')
.andWhere('asset.isArchived = false')
.andWhere("person.thumbnailPath != ''")
.select('COUNT(DISTINCT(person.id))', 'total')
.addSelect('COUNT(DISTINCT(person.id)) FILTER (WHERE person.isHidden = true)', 'hidden')
.having('COUNT(face.assetId) != 0')
.groupBy('person.id')
.withDeleted()
.getCount();
.getRawOne();
const result: PeopleStatistics = {
total: items ? Number.parseInt(items.total) : 0,
hidden: items ? Number.parseInt(items.hidden) : 0,
};
return result;
}
create(entity: Partial<PersonEntity>): Promise<PersonEntity> {
+10 -1
View File
@@ -26,6 +26,7 @@ FROM
WHERE
"person"."ownerId" = $1
AND "asset"."isArchived" = false
AND "person"."thumbnailPath" != ''
AND "person"."isHidden" = false
GROUP BY
"person"."id"
@@ -344,12 +345,20 @@ LIMIT
-- PersonRepository.getNumberOfPeople
SELECT
COUNT(DISTINCT ("person"."id")) AS "cnt"
COUNT(DISTINCT ("person"."id")) AS "total",
COUNT(DISTINCT ("person"."id")) FILTER (
WHERE
"person"."isHidden" = true
) AS "hidden"
FROM
"person" "person"
LEFT JOIN "asset_faces" "face" ON "face"."personId" = "person"."id"
INNER JOIN "assets" "asset" ON "asset"."id" = "face"."assetId"
AND ("asset"."deletedAt" IS NULL)
WHERE
"person"."ownerId" = $1
AND "asset"."isArchived" = false
AND "person"."thumbnailPath" != ''
HAVING
COUNT("face"."assetId") != 0
+4 -4
View File
@@ -32,7 +32,7 @@
"@socket.io/component-emitter": "^3.1.0",
"@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/enhanced-img": "^0.1.8",
"@sveltejs/kit": "^2.5.0",
"@sveltejs/kit": "^2.5.1",
"@sveltejs/vite-plugin-svelte": "^3.0.2",
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/svelte": "^4.0.3",
@@ -1859,9 +1859,9 @@
}
},
"node_modules/@sveltejs/kit": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.0.tgz",
"integrity": "sha512-1uyXvzC2Lu1FZa30T4y5jUAC21R309ZMRG0TPt+PPPbNUoDpy8zSmSNVWYaBWxYDqLGQ5oPNWvjvvF2IjJ1jmA==",
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.1.tgz",
"integrity": "sha512-TKj08o3mJCoQNLTdRdGkHPePTCPUGTgkew65RDqjVU3MtPVxljsofXQYfXndHfq0P7KoPRO/0/reF6HesU0Djw==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
+1 -1
View File
@@ -27,7 +27,7 @@
"@socket.io/component-emitter": "^3.1.0",
"@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/enhanced-img": "^0.1.8",
"@sveltejs/kit": "^2.5.0",
"@sveltejs/kit": "^2.5.1",
"@sveltejs/vite-plugin-svelte": "^3.0.2",
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/svelte": "^4.0.3",
@@ -14,12 +14,14 @@
import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
import type { SettingsEventType } from '../admin-settings';
import SettingAccordion from '../setting-accordion.svelte';
import SettingButtonsRow from '../setting-buttons-row.svelte';
import SettingCheckboxes from '../setting-checkboxes.svelte';
import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte';
import SettingSelect from '../setting-select.svelte';
import SettingSwitch from '../setting-switch.svelte';
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import SettingInputField, {
SettingInputFieldType,
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import SettingCheckboxes from '$lib/components/shared-components/settings/setting-checkboxes.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto;
@@ -5,8 +5,10 @@
import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
import type { SettingsEventType } from '../admin-settings';
import SettingButtonsRow from '../setting-buttons-row.svelte';
import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingInputField, {
SettingInputFieldType,
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto;
@@ -4,10 +4,12 @@
import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
import type { SettingsEventType } from '../admin-settings';
import SettingAccordion from '../setting-accordion.svelte';
import SettingButtonsRow from '../setting-buttons-row.svelte';
import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte';
import SettingSwitch from '../setting-switch.svelte';
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import SettingInputField, {
SettingInputFieldType,
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto;
@@ -4,9 +4,9 @@
import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
import type { SettingsEventType } from '../admin-settings';
import SettingButtonsRow from '../setting-buttons-row.svelte';
import SettingSelect from '../setting-select.svelte';
import SettingSwitch from '../setting-switch.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto;
@@ -4,11 +4,13 @@
import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
import type { SettingsEventType } from '../admin-settings';
import SettingAccordion from '../setting-accordion.svelte';
import SettingButtonsRow from '../setting-buttons-row.svelte';
import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte';
import SettingSelect from '../setting-select.svelte';
import SettingSwitch from '../setting-switch.svelte';
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingInputField, {
SettingInputFieldType,
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto;
@@ -4,10 +4,12 @@
import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
import type { SettingsEventType } from '../admin-settings';
import SettingAccordion from '../setting-accordion.svelte';
import SettingButtonsRow from '../setting-buttons-row.svelte';
import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte';
import SettingSwitch from '../setting-switch.svelte';
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import SettingInputField, {
SettingInputFieldType,
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto;
@@ -4,8 +4,8 @@
import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
import type { SettingsEventType } from '../admin-settings';
import SettingButtonsRow from '../setting-buttons-row.svelte';
import SettingSwitch from '../setting-switch.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto;
@@ -5,9 +5,11 @@
import { fade } from 'svelte/transition';
import type { SettingsEventType } from '../admin-settings';
import ConfirmDisableLogin from '../confirm-disable-login.svelte';
import SettingButtonsRow from '../setting-buttons-row.svelte';
import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte';
import SettingSwitch from '../setting-switch.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingInputField, {
SettingInputFieldType,
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto;
@@ -5,8 +5,8 @@
import { fade } from 'svelte/transition';
import type { SettingsEventType } from '../admin-settings';
import ConfirmDisableLogin from '../confirm-disable-login.svelte';
import SettingButtonsRow from '../setting-buttons-row.svelte';
import SettingSwitch from '../setting-switch.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto;
@@ -1,11 +1,13 @@
<script lang="ts">
import SettingButtonsRow from '$lib/components/admin-page/settings/setting-buttons-row.svelte';
import type { SystemConfigDto } from '@immich/sdk';
import { isEqual } from 'lodash-es';
import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
import type { SettingsEventType } from '../admin-settings';
import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte';
import SettingInputField, {
SettingInputFieldType,
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto;
@@ -13,11 +13,13 @@
import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
import type { SettingsEventType } from '../admin-settings';
import SettingButtonsRow from '../setting-buttons-row.svelte';
import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte';
import SettingSwitch from '../setting-switch.svelte';
import SupportedDatetimePanel from './supported-datetime-panel.svelte';
import SupportedVariablesPanel from './supported-variables-panel.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingInputField, {
SettingInputFieldType,
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto;
@@ -1,11 +1,12 @@
<script lang="ts">
import { locale } from '$lib/stores/preferences.store';
import type { SystemConfigTemplateStorageOptionDto } from '@immich/sdk';
import * as luxon from 'luxon';
import { DateTime } from 'luxon';
export let options: SystemConfigTemplateStorageOptionDto;
const getLuxonExample = (format: string) => {
return luxon.DateTime.fromISO(new Date('2022-09-04T20:03:05.250').toISOString()).toFormat(format);
return DateTime.fromISO('2022-09-04T20:03:05.250Z', { locale: $locale }).toFormat(format);
};
</script>
@@ -4,8 +4,8 @@
import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
import type { SettingsEventType } from '../admin-settings';
import SettingButtonsRow from '../setting-buttons-row.svelte';
import SettingTextarea from '../setting-textarea.svelte';
import SettingTextarea from '$lib/components/shared-components/settings/setting-textarea.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto;
@@ -1,13 +1,16 @@
<script lang="ts">
import SettingButtonsRow from '$lib/components/admin-page/settings/setting-buttons-row.svelte';
import SettingSelect from '$lib/components/admin-page/settings/setting-select.svelte';
import { Colorspace, type SystemConfigDto } from '@immich/sdk';
import { isEqual } from 'lodash-es';
import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
import type { SettingsEventType } from '../admin-settings';
import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte';
import SettingSwitch from '../setting-switch.svelte';
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingInputField, {
SettingInputFieldType,
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto;
@@ -4,9 +4,11 @@
import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
import type { SettingsEventType } from '../admin-settings';
import SettingButtonsRow from '../setting-buttons-row.svelte';
import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte';
import SettingSwitch from '../setting-switch.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import SettingInputField, {
SettingInputFieldType,
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto;
@@ -3,10 +3,11 @@
import type { AlbumResponseDto, UserResponseDto } from '@immich/sdk';
import { mdiClose, mdiPlus } from '@mdi/js';
import { createEventDispatcher } from 'svelte';
import SettingSwitch from '../admin-page/settings/setting-switch.svelte';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import FullScreenModal from '../shared-components/full-screen-modal.svelte';
import UserAvatar from '../shared-components/user-avatar.svelte';
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
export let album: AlbumResponseDto;
export let user: UserResponseDto;
@@ -24,12 +24,13 @@
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import { NotificationType, notificationController } from '../shared-components/notification/notification';
import UserAvatar from '../shared-components/user-avatar.svelte';
import { locale } from '$lib/stores/preferences.store';
const units: Intl.RelativeTimeFormatUnit[] = ['year', 'month', 'week', 'day', 'hour', 'minute', 'second'];
const shouldGroup = (currentDate: string, nextDate: string): boolean => {
const currentDateTime = luxon.DateTime.fromISO(currentDate);
const nextDateTime = luxon.DateTime.fromISO(nextDate);
const currentDateTime = luxon.DateTime.fromISO(currentDate, { locale: $locale });
const nextDateTime = luxon.DateTime.fromISO(nextDate, { locale: $locale });
return currentDateTime.hasSame(nextDateTime, 'hour') || currentDateTime.toRelative() === nextDateTime.toRelative();
};
@@ -224,7 +225,7 @@
class="pt-1 px-2 text-right w-full text-sm text-gray-500 dark:text-gray-300"
title={new Date(reaction.createdAt).toLocaleDateString(undefined, timeOptions)}
>
{timeSince(luxon.DateTime.fromISO(reaction.createdAt))}
{timeSince(luxon.DateTime.fromISO(reaction.createdAt, { locale: $locale }))}
</div>
{/if}
{:else if reaction.type === 'like'}
@@ -269,7 +270,7 @@
class="pt-1 px-2 text-right w-full text-sm text-gray-500 dark:text-gray-300"
title={new Date(reaction.createdAt).toLocaleDateString(navigator.language, timeOptions)}
>
{timeSince(luxon.DateTime.fromISO(reaction.createdAt))}
{timeSince(luxon.DateTime.fromISO(reaction.createdAt, { locale: $locale }))}
</div>
{/if}
</div>
@@ -443,6 +443,7 @@
{@const assetDateTimeOriginal = asset.exifInfo?.dateTimeOriginal
? DateTime.fromISO(asset.exifInfo.dateTimeOriginal, {
zone: asset.exifInfo.timeZone ?? undefined,
locale: $locale,
})
: DateTime.now()}
<ChangeDate
@@ -1,12 +1,13 @@
<script lang="ts">
import { mdiClose, mdiMagnify } from '@mdi/js';
import Icon from '../elements/icon.svelte';
import Icon from './icon.svelte';
import { createEventDispatcher } from 'svelte';
import type { SearchOptions } from '$lib/utils/dipatch';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
export let name: string;
export let isSearchingPeople: boolean;
export let isSearching: boolean;
export let placeholder: string;
const dispatch = createEventDispatcher<{ search: SearchOptions; reset: void }>();
@@ -27,11 +28,11 @@
autofocus
class="w-full gap-2 bg-gray-100 dark:bg-gray-700 dark:text-white"
type="text"
placeholder="Search names"
{placeholder}
bind:value={name}
on:input={() => dispatch('search', { force: false })}
/>
{#if isSearchingPeople}
{#if isSearching}
<div class="flex place-items-center">
<LoadingSpinner />
</div>
@@ -5,7 +5,7 @@
import { searchPerson, type PersonResponseDto } from '@immich/sdk';
import { createEventDispatcher } from 'svelte';
import FaceThumbnail from './face-thumbnail.svelte';
import SearchBar from './search-bar.svelte';
import SearchBar from '../elements/search-bar.svelte';
export let screenHeight: number;
export let people: PersonResponseDto[];
@@ -55,7 +55,8 @@
<div class=" w-40 sm:w-48 md:w-96 h-14 mb-8">
<SearchBar
bind:name
{isSearchingPeople}
isSearching={isSearchingPeople}
placeholder="Search people"
on:reset={() => {
people = peopleCopy;
}}
@@ -6,6 +6,7 @@
import { createEventDispatcher } from 'svelte';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { mdiClose, mdiEye, mdiEyeOff, mdiRestart } from '@mdi/js';
import { locale } from '$lib/stores/preferences.store';
const dispatch = createEventDispatcher<{
close: void;
@@ -17,6 +18,7 @@
export let showLoadingSpinner: boolean;
export let toggleVisibility: boolean;
export let screenHeight: number;
export let countTotalPeople: number;
</script>
<section
@@ -28,7 +30,10 @@
>
<div class="flex items-center">
<CircleIconButton icon={mdiClose} on:click={() => dispatch('close')} />
<p class="ml-4 hidden sm:block">Show & hide people</p>
<div class="flex gap-2 items-center">
<p class="ml-2">Show & hide people</p>
<p class="text-sm text-gray-400 dark:text-gray-600">({countTotalPeople.toLocaleString($locale)})</p>
</div>
</div>
<div class="flex items-center justify-end">
<div class="flex items-center md:mr-8">
@@ -47,7 +52,7 @@
</div>
</div>
<div class="flex w-full flex-wrap gap-1 bg-immich-bg p-2 pb-8 dark:bg-immich-dark-bg md:px-8 mt-16">
<div class="flex flex-wrap gap-1 bg-immich-bg p-2 pb-8 dark:bg-immich-dark-bg md:px-8 mt-16">
<slot />
</div>
</section>
@@ -11,7 +11,7 @@
export let scrollbar = true;
export let admin = false;
$: scrollbarClass = scrollbar ? 'immich-scrollbar p-4 pb-8' : 'scrollbar-hidden';
$: scrollbarClass = scrollbar ? 'immich-scrollbar p-2 pb-8' : 'scrollbar-hidden';
$: hasTitleClass = title ? 'top-16 h-[calc(100%-theme(spacing.16))]' : 'top-0 h-full';
</script>
@@ -4,10 +4,10 @@
import { Duration } from 'luxon';
import { createEventDispatcher } from 'svelte';
import { fly } from 'svelte/transition';
import SettingSelect from '../admin-page/settings/setting-select.svelte';
import SettingSwitch from '../admin-page/settings/setting-switch.svelte';
import Button from '../elements/buttons/button.svelte';
import LinkButton from '../elements/buttons/link-button.svelte';
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
export let settings: MapSettings;
let customDateRange = !!settings.dateAfter || !!settings.dateBefore;
@@ -26,8 +26,9 @@
<p class="text-xl text-immich-primary dark:text-immich-dark-primary">STORAGE TEMPLATE</p>
<p>
The storage template is used to determine the folder structure and file name of your media files. You can use
variables to customize the template to your liking.
When enabled, this feature will auto-organize files based on a user-defined template. Due to stability issues the
feature has been turned off by default. For more information, please see the
<a class="underline" href="https://immich.app/docs/administration/storage-template">documentation</a>.
</p>
{#if config && $user}
@@ -3,6 +3,7 @@
import { DateTime } from 'luxon';
import ConfirmDialogue from './confirm-dialogue.svelte';
import Combobox from './combobox.svelte';
export let initialDate: DateTime = DateTime.now();
type ZoneOption = {
@@ -28,7 +29,7 @@
const initialOption = timezones.find((item) => item.value === 'UTC' + initialDate.toFormat('ZZ'));
let selectedOption = {
let selectedOption = initialOption && {
label: initialOption?.label || '',
value: initialOption?.value || '',
};
@@ -36,7 +37,7 @@
let selectedDate = initialDate.toFormat("yyyy-MM-dd'T'HH:mm");
// Keep local time if not it's really confusing
$: date = DateTime.fromISO(selectedDate).setZone(selectedOption.value, { keepLocalTime: true });
$: date = DateTime.fromISO(selectedDate).setZone(selectedOption?.value, { keepLocalTime: true });
const dispatch = createEventDispatcher<{
cancel: void;
@@ -82,7 +83,7 @@
</div>
<div class="flex flex-col w-full mt-2">
<label for="timezone">Timezone</label>
<Combobox bind:selectedOption options={timezones} placeholder="Search timezone..." />
<Combobox bind:selectedOption id="timezone" options={timezones} placeholder="Search timezone..." />
</div>
</div>
</ConfirmDialogue>
@@ -1,12 +1,7 @@
<script lang="ts" context="module">
// Necessary for eslint
/* eslint-disable @typescript-eslint/no-explicit-any */
type T = any;
export type Type = 'button' | 'submit' | 'reset';
export type ComboBoxOption = {
label: string;
value: T;
value: string;
};
</script>
@@ -15,34 +10,32 @@
import Icon from '$lib/components/elements/icon.svelte';
import { clickOutside } from '$lib/utils/click-outside';
import { mdiMagnify, mdiUnfoldMoreHorizontal } from '@mdi/js';
import { mdiMagnify, mdiUnfoldMoreHorizontal, mdiClose } from '@mdi/js';
import { createEventDispatcher } from 'svelte';
import IconButton from '../elements/buttons/icon-button.svelte';
export let type: Type = 'button';
export let id: string | undefined = undefined;
export let options: ComboBoxOption[] = [];
export let selectedOption: ComboBoxOption | undefined = undefined;
export let selectedOption: ComboBoxOption | undefined;
export let placeholder = '';
export const label = '';
export let noLabel = false;
let isOpen = false;
let searchQuery = '';
let searchQuery = selectedOption?.label || '';
$: filteredOptions = options.filter((option) => option.label.toLowerCase().includes(searchQuery.toLowerCase()));
const dispatch = createEventDispatcher<{
select: ComboBoxOption;
select: ComboBoxOption | undefined;
click: void;
}>();
let handleClick = () => {
const handleClick = () => {
searchQuery = '';
isOpen = !isOpen;
isOpen = true;
dispatch('click');
};
let handleOutClick = () => {
searchQuery = '';
isOpen = false;
};
@@ -51,49 +44,77 @@
dispatch('select', option);
isOpen = false;
};
const onClear = () => {
selectedOption = undefined;
searchQuery = '';
dispatch('select', selectedOption);
};
</script>
<div class="relative" use:clickOutside on:outclick={handleOutClick}>
<button {type} class="immich-form-input text-sm text-left w-full min-h-[48px] transition-all" on:click={handleClick}
>{#if !noLabel}
{selectedOption?.label || ''}
<div class="relative w-full dark:text-gray-300 text-gray-700 text-base" use:clickOutside on:outclick={handleOutClick}>
<div>
{#if isOpen}
<div class="absolute inset-y-0 left-0 flex items-center pl-3">
<div class="dark:text-immich-dark-fg/75">
<Icon path={mdiMagnify} />
</div>
</div>
{/if}
<div class="absolute right-0 top-0 h-full flex px-4 justify-center items-center content-between">
<Icon path={mdiUnfoldMoreHorizontal} />
<input
{id}
{placeholder}
role="combobox"
aria-expanded={isOpen}
aria-controls={id}
class="immich-form-input text-sm text-left w-full !pr-12 transition-all"
class:!pl-8={isOpen}
class:!rounded-b-none={isOpen}
class:cursor-pointer={!isOpen}
value={isOpen ? '' : selectedOption?.label || ''}
on:input={(e) => (searchQuery = e.currentTarget.value)}
on:focus={handleClick}
/>
<div
class="absolute right-0 top-0 h-full flex px-4 justify-center items-center content-between"
class:pr-2={selectedOption}
class:pointer-events-none={!selectedOption}
>
{#if selectedOption}
<IconButton color="transparent-gray" on:click={onClear} title="Clear value">
<Icon path={mdiClose} />
</IconButton>
{:else if !isOpen}
<Icon path={mdiUnfoldMoreHorizontal} />
{/if}
</div>
</button>
</div>
{#if isOpen}
<div
transition:fly={{ y: -25, duration: 250 }}
class="absolute w-full top-full mt-2 bg-white dark:bg-gray-800 rounded-lg border border-gray-300 dark:border-gray-900 z-10"
role="listbox"
transition:fly={{ duration: 250 }}
class="absolute text-left text-sm w-full max-h-64 overflow-y-auto bg-white dark:bg-gray-800 rounded-b-lg border border-t-0 border-gray-300 dark:border-gray-900 z-10"
>
<div class="relative border-b flex">
<div class="absolute inset-y-0 left-0 flex items-center pl-3">
<div class="dark:text-immich-dark-fg/75">
<button {type} class="flex items-center">
<Icon path={mdiMagnify} />
</button>
</div>
</div>
<!-- svelte-ignore a11y-autofocus -->
<input bind:value={searchQuery} autofocus {placeholder} class="ml-9 grow bg-transparent py-2" />
</div>
<div class="h-64 overflow-y-auto">
{#each filteredOptions as option (option.label)}
<button
{type}
class="block text-left w-full px-4 py-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 transition-all
${option.label === selectedOption?.label ? 'bg-gray-300 dark:bg-gray-600' : ''}
"
class:bg-gray-300={option.label === selectedOption?.label}
on:click={() => handleSelect(option)}
>
{option.label}
</button>
{/each}
</div>
{#if filteredOptions.length === 0}
<div class="px-4 py-2 font-medium">No results</div>
{/if}
{#each filteredOptions as option (option.label)}
{@const selected = option.label === selectedOption?.label}
<button
type="button"
role="option"
aria-selected={selected}
class="text-left w-full px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all"
class:bg-gray-300={selected}
class:dark:bg-gray-600={selected}
on:click={() => handleSelect(option)}
>
{option.label}
</button>
{/each}
</div>
{/if}
</div>
@@ -1,8 +1,4 @@
<script lang="ts">
import SettingInputField, {
SettingInputFieldType,
} from '$lib/components/admin-page/settings/setting-input-field.svelte';
import SettingSwitch from '$lib/components/admin-page/settings/setting-switch.svelte';
import Button from '$lib/components/elements/buttons/button.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import { serverConfig } from '$lib/stores/server-config.store';
@@ -15,6 +11,8 @@
import type { ImmichDropDownOption } from '../dropdown-button.svelte';
import DropdownButton from '../dropdown-button.svelte';
import { NotificationType, notificationController } from '../notification/notification';
import SettingInputField, { SettingInputFieldType } from '../settings/setting-input-field.svelte';
import SettingSwitch from '../settings/setting-switch.svelte';
export let albumId: string | undefined = undefined;
export let assetIds: string[] = [];
@@ -2,12 +2,7 @@
import { AppRoute } from '$lib/constants';
import Icon from '$lib/components/elements/icon.svelte';
import { goto } from '$app/navigation';
import {
isSearchEnabled,
preventRaceConditionSearchBar,
savedSearchTerms,
searchQuery,
} from '$lib/stores/search.store';
import { isSearchEnabled, preventRaceConditionSearchBar, savedSearchTerms } from '$lib/stores/search.store';
import { clickOutside } from '$lib/utils/click-outside';
import { mdiClose, mdiMagnify, mdiTune } from '@mdi/js';
import IconButton from '$lib/components/elements/buttons/icon-button.svelte';
@@ -15,8 +10,10 @@
import SearchFilterBox from './search-filter-box.svelte';
import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk';
import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
export let value = '';
export let grayTheme: boolean;
export let searchQuery: MetadataSearchDto | SmartSearchDto = {};
let input: HTMLInputElement;
@@ -30,8 +27,7 @@
showHistory = false;
showFilter = false;
$isSearchEnabled = false;
$searchQuery = payload;
goto(`${AppRoute.SEARCH}?${params}`, { invalidateAll: true });
goto(`${AppRoute.SEARCH}?${params}`);
};
const clearSearchTerm = (searchTerm: string) => {
@@ -87,11 +83,11 @@
};
</script>
<div role="button" class="w-full" use:clickOutside on:outclick={onFocusOut} on:escape={onFocusOut}>
<div class="w-full relative" use:clickOutside on:outclick={onFocusOut} on:escape={onFocusOut}>
<form
draggable="false"
autocomplete="off"
class="relative select-text text-sm"
class="select-text text-sm"
action={AppRoute.SEARCH}
on:reset={() => (value = '')}
on:submit|preventDefault={onSubmit}
@@ -148,9 +144,9 @@
on:selectSearchTerm={({ detail: searchTerm }) => onHistoryTermClick(searchTerm)}
/>
{/if}
{#if showFilter}
<SearchFilterBox on:search={({ detail }) => onSearch(detail)} />
{/if}
</form>
{#if showFilter}
<SearchFilterBox {searchQuery} on:search={({ detail }) => onSearch(detail)} />
{/if}
</div>
@@ -17,7 +17,6 @@
import { fly } from 'svelte/transition';
import Combobox, { type ComboBoxOption } from '../combobox.svelte';
import { DateTime } from 'luxon';
import { searchQuery } from '$lib/stores/search.store';
enum MediaType {
All = 'all',
@@ -44,7 +43,7 @@
type SearchFilter = {
context?: string;
people: PersonResponseDto[];
people: (PersonResponseDto | Pick<PersonResponseDto, 'id'>)[];
location: {
country?: ComboBoxOption;
@@ -69,6 +68,8 @@
mediaType: MediaType;
};
export let searchQuery: MetadataSearchDto | SmartSearchDto;
let suggestions: SearchSuggestion = {
people: [],
country: [],
@@ -112,19 +113,19 @@
populateExistingFilters();
});
const showSelectedPeopleFirst = () => {
suggestions.people.sort((a, _) => {
function orderBySelectedPeopleFirst<T extends Pick<PersonResponseDto, 'id'>>(people: T[]) {
return people.sort((a, _) => {
if (filter.people.some((p) => p.id === a.id)) {
return -1;
}
return 1;
});
};
}
const getPeople = async () => {
try {
const { people } = await getAllPeople({ withHidden: false });
suggestions.people = people;
suggestions.people = orderBySelectedPeopleFirst(people);
} catch (error) {
handleError(error, 'Failed to get people');
}
@@ -133,14 +134,12 @@
const handlePeopleSelection = (id: string) => {
if (filter.people.some((p) => p.id === id)) {
filter.people = filter.people.filter((p) => p.id !== id);
showSelectedPeopleFirst();
return;
}
const person = suggestions.people.find((p) => p.id === id);
if (person) {
filter.people = [...filter.people, person];
showSelectedPeopleFirst();
}
};
@@ -280,35 +279,36 @@
};
function populateExistingFilters() {
if ($searchQuery) {
if (searchQuery) {
const personIds = 'personIds' in searchQuery && searchQuery.personIds ? searchQuery.personIds : [];
filter = {
context: 'query' in $searchQuery ? $searchQuery.query : '',
people:
'personIds' in $searchQuery ? ($searchQuery.personIds?.map((id) => ({ id })) as PersonResponseDto[]) : [],
context: 'query' in searchQuery ? searchQuery.query : '',
people: orderBySelectedPeopleFirst(personIds.map((id) => ({ id }))),
location: {
country: $searchQuery.country ? { label: $searchQuery.country, value: $searchQuery.country } : undefined,
state: $searchQuery.state ? { label: $searchQuery.state, value: $searchQuery.state } : undefined,
city: $searchQuery.city ? { label: $searchQuery.city, value: $searchQuery.city } : undefined,
country: searchQuery.country ? { label: searchQuery.country, value: searchQuery.country } : undefined,
state: searchQuery.state ? { label: searchQuery.state, value: searchQuery.state } : undefined,
city: searchQuery.city ? { label: searchQuery.city, value: searchQuery.city } : undefined,
},
camera: {
make: $searchQuery.make ? { label: $searchQuery.make, value: $searchQuery.make } : undefined,
model: $searchQuery.model ? { label: $searchQuery.model, value: $searchQuery.model } : undefined,
make: searchQuery.make ? { label: searchQuery.make, value: searchQuery.make } : undefined,
model: searchQuery.model ? { label: searchQuery.model, value: searchQuery.model } : undefined,
},
date: {
takenAfter: $searchQuery.takenAfter
? DateTime.fromISO($searchQuery.takenAfter).toUTC().toFormat('yyyy-MM-dd')
takenAfter: searchQuery.takenAfter
? DateTime.fromISO(searchQuery.takenAfter).toUTC().toFormat('yyyy-MM-dd')
: undefined,
takenBefore: $searchQuery.takenBefore
? DateTime.fromISO($searchQuery.takenBefore).toUTC().toFormat('yyyy-MM-dd')
takenBefore: searchQuery.takenBefore
? DateTime.fromISO(searchQuery.takenBefore).toUTC().toFormat('yyyy-MM-dd')
: undefined,
},
isArchive: $searchQuery.isArchived,
isFavorite: $searchQuery.isFavorite,
isNotInAlbum: 'isNotInAlbum' in $searchQuery ? $searchQuery.isNotInAlbum : undefined,
isArchive: searchQuery.isArchived,
isFavorite: searchQuery.isFavorite,
isNotInAlbum: 'isNotInAlbum' in searchQuery ? searchQuery.isNotInAlbum : undefined,
mediaType:
$searchQuery.type === AssetTypeEnum.Image
searchQuery.type === AssetTypeEnum.Image
? MediaType.Image
: $searchQuery.type === AssetTypeEnum.Video
: searchQuery.type === AssetTypeEnum.Video
? MediaType.Video
: MediaType.All,
};
@@ -344,7 +344,7 @@
{#each peopleList as person (person.id)}
<button
type="button"
class="w-20 text-center rounded-3xl border-2 border-transparent hover:bg-immich-gray dark:hover:bg-immich-dark-primary/20 p-2 flex-col place-items-center transition-all {filter.people.some(
class="w-20 text-center rounded-3xl border-2 border-transparent hover:bg-immich-gray dark:hover:bg-immich-dark-primary/20 p-2 transition-all {filter.people.some(
(p) => p.id === person.id,
)
? 'dark:border-slate-500 border-slate-300 bg-slate-200 dark:bg-slate-800 dark:text-white'
@@ -356,9 +356,9 @@
shadow
url={getPeopleThumbnailUrl(person.id)}
altText={person.name}
widthStyle="100px"
widthStyle="100%"
/>
<p class="mt-2 text-ellipsis text-sm font-medium dark:text-white">{person.name}</p>
<p class="mt-2 line-clamp-2 text-sm font-medium dark:text-white">{person.name}</p>
</button>
{/each}
</div>
@@ -404,8 +404,9 @@
<div class="flex justify-between gap-5 mt-3">
<div class="w-full">
<p class="text-sm text-black dark:text-white">Country</p>
<label class="text-sm text-black dark:text-white" for="search-place-country">Country</label>
<Combobox
id="search-place-country"
options={suggestions.country}
bind:selectedOption={filter.location.country}
placeholder="Search country..."
@@ -414,8 +415,9 @@
</div>
<div class="w-full">
<p class="text-sm text-black dark:text-white">State</p>
<label class="text-sm text-black dark:text-white" for="search-place-state">State</label>
<Combobox
id="search-place-state"
options={suggestions.state}
bind:selectedOption={filter.location.state}
placeholder="Search state..."
@@ -424,8 +426,9 @@
</div>
<div class="w-full">
<p class="text-sm text-black dark:text-white">City</p>
<label class="text-sm text-black dark:text-white" for="search-place-city">City</label>
<Combobox
id="search-place-city"
options={suggestions.city}
bind:selectedOption={filter.location.city}
placeholder="Search city..."
@@ -446,8 +449,9 @@
<div class="flex justify-between gap-5 mt-3">
<div class="w-full">
<p class="text-sm text-black dark:text-white">Make</p>
<label class="text-sm text-black dark:text-white" for="search-camera-make">Make</label>
<Combobox
id="search-camera-make"
options={suggestions.make}
bind:selectedOption={filter.camera.make}
placeholder="Search camera make..."
@@ -457,8 +461,9 @@
</div>
<div class="w-full">
<p class="text-sm text-black dark:text-white">Model</p>
<label class="text-sm text-black dark:text-white" for="search-camera-model">Model</label>
<Combobox
id="search-camera-model"
options={suggestions.model}
bind:selectedOption={filter.camera.model}
placeholder="Search camera model..."
@@ -498,7 +503,7 @@
</div>
<hr class="border-slate-300 dark:border-slate-700" />
<div class="py-3 grid grid-cols-2">
<div class="py-3 grid grid-cols-[repeat(auto-fill,minmax(21rem,1fr))] gap-x-16 gap-y-8">
<!-- MEDIA TYPE -->
<div id="media-type-selection">
<p class="immich-form-label">MEDIA TYPE</p>
@@ -0,0 +1,42 @@
<script lang="ts">
import { quintOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte';
export let title: string;
export let comboboxPlaceholder: string;
export let subtitle = '';
export let isEdited = false;
export let options: ComboBoxOption[];
export let selectedOption: ComboBoxOption;
export let onSelect: (combobox: ComboBoxOption | undefined) => void;
</script>
<div class="grid grid-cols-2">
<div>
<div class="flex h-[26px] place-items-center gap-1">
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for={title}>
{title}
</label>
{#if isEdited}
<div
transition:fly={{ x: 10, duration: 200, easing: quintOut }}
class="rounded-full bg-orange-100 px-2 text-[10px] text-orange-900"
>
Unsaved change
</div>
{/if}
</div>
<p class="text-sm dark:text-immich-dark-fg">{subtitle}</p>
</div>
<div class="flex items-center">
<Combobox
{selectedOption}
{options}
placeholder={comboboxPlaceholder}
on:select={({ detail }) => onSelect(detail)}
/>
<slot />
</div>
</div>
@@ -30,6 +30,7 @@
</div>
<p class="text-sm dark:text-immich-dark-fg">{subtitle}</p>
<slot />
</div>
<label class="relative inline-block h-[10px] w-[36px] flex-none">
@@ -16,6 +16,7 @@
import { createEventDispatcher } from 'svelte';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import { locale } from '$lib/stores/preferences.store';
export let link: SharedLinkResponseDto;
@@ -43,7 +44,7 @@
return;
}
const expiresAtDate = luxon.DateTime.fromISO(new Date(link.expiresAt).toISOString());
const expiresAtDate = luxon.DateTime.fromISO(new Date(link.expiresAt).toISOString(), { locale: $locale });
const now = luxon.DateTime.now();
expirationCountdown = expiresAtDate.diff(now, ['days', 'hours', 'minutes', 'seconds']).toObject();
@@ -1,11 +1,64 @@
<script lang="ts">
import type { ComboBoxOption } from '$lib/components/shared-components/combobox.svelte';
import SettingCombobox from '$lib/components/shared-components/settings/setting-combobox.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { fallbackLocale, locales } from '$lib/constants';
import { sidebarSettings } from '$lib/stores/preferences.store';
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
import { colorTheme, locale } from '$lib/stores/preferences.store';
import { findLocale } from '$lib/utils';
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
import { colorTheme } from '../../stores/preferences.store';
import SettingSwitch from '../admin-page/settings/setting-switch.svelte';
export const handleToggle = () => {
let time = new Date();
$: formattedDate = time.toLocaleString(editedLocale, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
$: timePortion = time.toLocaleString(editedLocale, {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
$: selectedDate = `${formattedDate} ${timePortion}`;
$: editedLocale = findLocale($locale).code;
$: selectedOption = {
value: findLocale(editedLocale).code || fallbackLocale.code,
label: findLocale(editedLocale).name || fallbackLocale.name,
};
onMount(() => {
const interval = setInterval(() => {
time = new Date();
}, 1000);
return () => {
clearInterval(interval);
};
});
const getAllLanguages = (): ComboBoxOption[] => {
return locales
.filter(({ code }) => Intl.NumberFormat.supportedLocalesOf(code).length > 0)
.map((locale) => ({
label: locale.name,
value: locale.code,
}));
};
const handleToggleColorTheme = () => {
$colorTheme.system = !$colorTheme.system;
};
const handleToggleLocaleBrowser = () => {
$locale = $locale ? undefined : fallbackLocale.code;
};
const handleLocaleChange = (newLocale: string | undefined) => {
$locale = newLocale;
};
</script>
<section class="my-4">
@@ -16,7 +69,54 @@
title="Theme selection"
subtitle="Automatically set the theme to light or dark based on your browser's system preference"
bind:checked={$colorTheme.system}
on:toggle={handleToggle}
on:toggle={handleToggleColorTheme}
/>
</div>
<div class="ml-4">
<SettingSwitch
title="Default Locale"
subtitle="Format dates and numbers based on your browser locale"
checked={$locale == undefined}
on:toggle={handleToggleLocaleBrowser}
>
<p class="mt-2 dark:text-gray-400">{selectedDate}</p>
</SettingSwitch>
</div>
{#if $locale !== undefined}
<div class="ml-4">
<SettingCombobox
comboboxPlaceholder="Searching locales..."
{selectedOption}
options={getAllLanguages()}
title="Custom Locale"
subtitle="Format dates and numbers based on the language and the region"
onSelect={(combobox) => handleLocaleChange(combobox?.value)}
/>
</div>
{/if}
<div class="ml-4">
<SettingSwitch
title="Display original photos"
subtitle="Prefer to display the original photo when viewing an asset rather than thumbnails when the original asset is web-compatible. This may result in slower photo display speeds."
bind:checked={$alwaysLoadOriginalFile}
on:toggle={() => ($alwaysLoadOriginalFile = !$alwaysLoadOriginalFile)}
/>
</div>
<div class="ml-4">
<SettingSwitch
title="People"
subtitle="Display a link to People in the sidebar"
bind:checked={$sidebarSettings.people}
/>
</div>
<div class="ml-4">
<SettingSwitch
title="Sharing"
subtitle="Display a link to Sharing in the sidebar"
bind:checked={$sidebarSettings.sharing}
/>
</div>
</div>
@@ -5,9 +5,12 @@
} from '$lib/components/shared-components/notification/notification';
import { changePassword } from '@immich/sdk';
import { fade } from 'svelte/transition';
import SettingInputField, { SettingInputFieldType } from '../admin-page/settings/setting-input-field.svelte';
import Button from '../elements/buttons/button.svelte';
import Button from '$lib/components/elements/buttons/button.svelte';
import type { HttpError } from '@sveltejs/kit';
import SettingInputField, {
SettingInputFieldType,
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
let password = '';
let newPassword = '';
@@ -57,7 +57,7 @@
</span>
<div class="text-sm">
<span class="">Last seen</span>
<span>{DateTime.fromISO(device.updatedAt).toRelativeCalendar(options)}</span>
<span>{DateTime.fromISO(device.updatedAt, { locale: $locale }).toRelativeCalendar(options)}</span>
</div>
</div>
{#if !device.current}
@@ -6,8 +6,9 @@
import { updateUser, type UserResponseDto } from '@immich/sdk';
import { fade } from 'svelte/transition';
import { handleError } from '../../utils/handle-error';
import SettingSwitch from '../admin-page/settings/setting-switch.svelte';
import Button from '../elements/buttons/button.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
export let user: UserResponseDto;
@@ -10,13 +10,13 @@
import { mdiCheck, mdiClose } from '@mdi/js';
import { onMount } from 'svelte';
import { handleError } from '../../utils/handle-error';
import SettingSwitch from '../admin-page/settings/setting-switch.svelte';
import Button from '../elements/buttons/button.svelte';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import Icon from '../elements/icon.svelte';
import ConfirmDialogue from '../shared-components/confirm-dialogue.svelte';
import UserAvatar from '../shared-components/user-avatar.svelte';
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
import PartnerSelectionModal from './partner-selection-modal.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
interface PartnerSharing {
user: UserResponseDto;
@@ -1,24 +0,0 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import { alwaysLoadOriginalFile } from '../../stores/preferences.store';
import SettingSwitch from '../admin-page/settings/setting-switch.svelte';
const handleToggle = () => {
$alwaysLoadOriginalFile = !$alwaysLoadOriginalFile;
};
</script>
<section class="my-4">
<div in:fade={{ duration: 500 }}>
<div class="ml-4 mt-4 flex flex-col gap-4">
<div class="ml-4">
<SettingSwitch
title="Display original photos"
subtitle="Prefer to display the original photo when viewing an asset rather than thumbnails when the original asset is web-compatible. This may result in slower photo display speeds."
bind:checked={$alwaysLoadOriginalFile}
on:toggle={handleToggle}
/>
</div>
</div>
</div>
</section>
@@ -1,18 +0,0 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import { sidebarSettings } from '../../stores/preferences.store';
import SettingSwitch from '../admin-page/settings/setting-switch.svelte';
</script>
<section class="my-4">
<div in:fade={{ duration: 500 }}>
<div class="ml-4 mt-4 flex flex-col gap-4">
<div class="ml-4">
<SettingSwitch title="People" subtitle="Display a link to People" bind:checked={$sidebarSettings.people} />
</div>
<div class="ml-4">
<SettingSwitch title="Sharing" subtitle="Display a link to Sharing" bind:checked={$sidebarSettings.sharing} />
</div>
</div>
</div>
</section>
@@ -1,7 +1,7 @@
<script lang="ts">
import SettingSwitch from '../admin-page/settings/setting-switch.svelte';
import { showDeleteModal } from '$lib/stores/preferences.store';
import { fade } from 'svelte/transition';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
</script>
<section class="my-4">
@@ -5,11 +5,13 @@
} from '$lib/components/shared-components/notification/notification';
import { fade } from 'svelte/transition';
import { handleError } from '../../utils/handle-error';
import SettingInputField, { SettingInputFieldType } from '../admin-page/settings/setting-input-field.svelte';
import Button from '../elements/buttons/button.svelte';
import { user } from '$lib/stores/user.store';
import { cloneDeep } from 'lodash-es';
import { updateUser } from '@immich/sdk';
import SettingInputField, {
SettingInputFieldType,
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
let editedUser = cloneDeep($user);
@@ -6,15 +6,13 @@
import { user } from '$lib/stores/user.store';
import { oauth } from '$lib/utils';
import { type ApiKeyResponseDto, type AuthDeviceResponseDto } from '@immich/sdk';
import SettingAccordion from '../admin-page/settings/setting-accordion.svelte';
import SettingAccordion from '../shared-components/settings/setting-accordion.svelte';
import AppearanceSettings from './appearance-settings.svelte';
import ChangePasswordSettings from './change-password-settings.svelte';
import DeviceList from './device-list.svelte';
import MemoriesSettings from './memories-settings.svelte';
import OAuthSettings from './oauth-settings.svelte';
import PartnerSettings from './partner-settings.svelte';
import QualitySettings from './quality-settings.svelte';
import SidebarSettings from './sidebar-settings.svelte';
import TrashSettings from './trash-settings.svelte';
import UserAPIKeyList from './user-api-key-list.svelte';
import UserProfileSettings from './user-profile-settings.svelte';
@@ -29,7 +27,7 @@
}
</script>
<SettingAccordion key="appearance" title="Appearance" subtitle="Manage your Immich appearance">
<SettingAccordion key="appearance" title="Appearance" subtitle="Manage the app appearance">
<AppearanceSettings />
</SettingAccordion>
@@ -69,18 +67,10 @@
<ChangePasswordSettings />
</SettingAccordion>
<SettingAccordion key="quality" title="Quality" subtitle="Manage your photo viewing experience">
<QualitySettings />
</SettingAccordion>
<SettingAccordion key="sharing" title="Sharing" subtitle="Manage sharing with partners">
<PartnerSettings user={$user} />
</SettingAccordion>
<SettingAccordion key="sidebar" title="Sidebar" subtitle="Manage sidebar settings">
<SidebarSettings />
</SettingAccordion>
<SettingAccordion key="trash" title="Trash" subtitle="Manage trash settings">
<TrashSettings />
</SettingAccordion>
+144 -1
View File
@@ -70,7 +70,6 @@ export enum QueryParameter {
PREVIOUS_ROUTE = 'previousRoute',
QUERY = 'query',
SEARCHED_PEOPLE = 'searchedPeople',
SEARCH_TERM = 'q',
SMART_SEARCH = 'smartSearch',
PAGE = 'page',
}
@@ -97,3 +96,147 @@ export enum Theme {
LIGHT = 'light',
DARK = 'dark',
}
export const fallbackLocale = {
code: 'en-US',
name: 'English (US)',
};
export const locales = [
{ code: 'af-ZA', name: 'Afrikaans (South Africa)' },
{ code: 'sq-AL', name: 'Albanian (Albania)' },
{ code: 'ar-DZ', name: 'Arabic (Algeria)' },
{ code: 'ar-BH', name: 'Arabic (Bahrain)' },
{ code: 'ar-EG', name: 'Arabic (Egypt)' },
{ code: 'ar-IQ', name: 'Arabic (Iraq)' },
{ code: 'ar-JO', name: 'Arabic (Jordan)' },
{ code: 'ar-KW', name: 'Arabic (Kuwait)' },
{ code: 'ar-LB', name: 'Arabic (Lebanon)' },
{ code: 'ar-LY', name: 'Arabic (Libya)' },
{ code: 'ar-MA', name: 'Arabic (Morocco)' },
{ code: 'ar-OM', name: 'Arabic (Oman)' },
{ code: 'ar-QA', name: 'Arabic (Qatar)' },
{ code: 'ar-SA', name: 'Arabic (Saudi Arabia)' },
{ code: 'ar-SY', name: 'Arabic (Syria)' },
{ code: 'ar-TN', name: 'Arabic (Tunisia)' },
{ code: 'ar-AE', name: 'Arabic (United Arab Emirates)' },
{ code: 'ar-YE', name: 'Arabic (Yemen)' },
{ code: 'hy-AM', name: 'Armenian (Armenia)' },
{ code: 'az-AZ', name: 'Azerbaijani (Azerbaijan)' },
{ code: 'eu-ES', name: 'Basque (Spain)' },
{ code: 'be-BY', name: 'Belarusian (Belarus)' },
{ code: 'bn-IN', name: 'Bengali (India)' },
{ code: 'bs-BA', name: 'Bosnian (Bosnia and Herzegovina)' },
{ code: 'bg-BG', name: 'Bulgarian (Bulgaria)' },
{ code: 'ca-ES', name: 'Catalan (Spain)' },
{ code: 'zh-CN', name: 'Chinese (China)' },
{ code: 'zh-HK', name: 'Chinese (Hong Kong SAR China)' },
{ code: 'zh-MO', name: 'Chinese (Macao SAR China)' },
{ code: 'zh-SG', name: 'Chinese (Singapore)' },
{ code: 'zh-TW', name: 'Chinese (Taiwan)' },
{ code: 'hr-HR', name: 'Croatian (Croatia)' },
{ code: 'cs-CZ', name: 'Czech (Czech Republic)' },
{ code: 'da-DK', name: 'Danish (Denmark)' },
{ code: 'nl-BE', name: 'Dutch (Belgium)' },
{ code: 'nl-NL', name: 'Dutch (Netherlands)' },
{ code: 'en-AU', name: 'English (Australia)' },
{ code: 'en-BZ', name: 'English (Belize)' },
{ code: 'en-CA', name: 'English (Canada)' },
{ code: 'en-IE', name: 'English (Ireland)' },
{ code: 'en-JM', name: 'English (Jamaica)' },
{ code: 'en-NZ', name: 'English (New Zealand)' },
{ code: 'en-PH', name: 'English (Philippines)' },
{ code: 'en-ZA', name: 'English (South Africa)' },
{ code: 'en-TT', name: 'English (Trinidad and Tobago)' },
{ code: 'en-VI', name: 'English (U.S. Virgin Islands)' },
{ code: 'en-GB', name: 'English (United Kingdom)' },
{ code: 'en-US', name: 'English (United States)' },
{ code: 'en-ZW', name: 'English (Zimbabwe)' },
{ code: 'et-EE', name: 'Estonian (Estonia)' },
{ code: 'fo-FO', name: 'Faroese (Faroe Islands)' },
{ code: 'fi-FI', name: 'Finnish (Finland)' },
{ code: 'fr-BE', name: 'French (Belgium)' },
{ code: 'fr-CA', name: 'French (Canada)' },
{ code: 'fr-FR', name: 'French (France)' },
{ code: 'fr-LU', name: 'French (Luxembourg)' },
{ code: 'fr-MC', name: 'French (Monaco)' },
{ code: 'fr-CH', name: 'French (Switzerland)' },
{ code: 'gl-ES', name: 'Galician (Spain)' },
{ code: 'ka-GE', name: 'Georgian (Georgia)' },
{ code: 'de-AT', name: 'German (Austria)' },
{ code: 'de-DE', name: 'German (Germany)' },
{ code: 'de-LI', name: 'German (Liechtenstein)' },
{ code: 'de-LU', name: 'German (Luxembourg)' },
{ code: 'de-CH', name: 'German (Switzerland)' },
{ code: 'el-GR', name: 'Greek (Greece)' },
{ code: 'gu-IN', name: 'Gujarati (India)' },
{ code: 'he-IL', name: 'Hebrew (Israel)' },
{ code: 'hi-IN', name: 'Hindi (India)' },
{ code: 'hu-HU', name: 'Hungarian (Hungary)' },
{ code: 'is-IS', name: 'Icelandic (Iceland)' },
{ code: 'id-ID', name: 'Indonesian (Indonesia)' },
{ code: 'it-IT', name: 'Italian (Italy)' },
{ code: 'it-CH', name: 'Italian (Switzerland)' },
{ code: 'ja-JP', name: 'Japanese (Japan)' },
{ code: 'kn-IN', name: 'Kannada (India)' },
{ code: 'kk-KZ', name: 'Kazakh (Kazakhstan)' },
{ code: 'kok-IN', name: 'Konkani (India)' },
{ code: 'ko-KR', name: 'Korean (South Korea)' },
{ code: 'lv-LV', name: 'Latvian (Latvia)' },
{ code: 'lt-LT', name: 'Lithuanian (Lithuania)' },
{ code: 'mk-MK', name: 'Macedonian (Macedonia)' },
{ code: 'ms-BN', name: 'Malay (Brunei)' },
{ code: 'ms-MY', name: 'Malay (Malaysia)' },
{ code: 'ml-IN', name: 'Malayalam (India)' },
{ code: 'mt-MT', name: 'Maltese (Malta)' },
{ code: 'mr-IN', name: 'Marathi (India)' },
{ code: 'mn-MN', name: 'Mongolian (Mongolia)' },
{ code: 'se-NO', name: 'Northern Sami (Norway)' },
{ code: 'nb-NO', name: 'Norwegian Bokmål (Norway)' },
{ code: 'nn-NO', name: 'Norwegian Nynorsk (Norway)' },
{ code: 'fa-IR', name: 'Persian (Iran)' },
{ code: 'pl-PL', name: 'Polish (Poland)' },
{ code: 'pt-BR', name: 'Portuguese (Brazil)' },
{ code: 'pt-PT', name: 'Portuguese (Portugal)' },
{ code: 'pa-IN', name: 'Punjabi (India)' },
{ code: 'ro-RO', name: 'Romanian (Romania)' },
{ code: 'ru-RU', name: 'Russian (Russia)' },
{ code: 'sr-BA', name: 'Serbian (Bosnia and Herzegovina)' },
{ code: 'sr-CS', name: 'Serbian (Serbia And Montenegro)' },
{ code: 'sk-SK', name: 'Slovak (Slovakia)' },
{ code: 'sl-SI', name: 'Slovenian (Slovenia)' },
{ code: 'es-AR', name: 'Spanish (Argentina)' },
{ code: 'es-BO', name: 'Spanish (Bolivia)' },
{ code: 'es-CL', name: 'Spanish (Chile)' },
{ code: 'es-CO', name: 'Spanish (Colombia)' },
{ code: 'es-CR', name: 'Spanish (Costa Rica)' },
{ code: 'es-DO', name: 'Spanish (Dominican Republic)' },
{ code: 'es-EC', name: 'Spanish (Ecuador)' },
{ code: 'es-SV', name: 'Spanish (El Salvador)' },
{ code: 'es-GT', name: 'Spanish (Guatemala)' },
{ code: 'es-HN', name: 'Spanish (Honduras)' },
{ code: 'es-MX', name: 'Spanish (Mexico)' },
{ code: 'es-NI', name: 'Spanish (Nicaragua)' },
{ code: 'es-PA', name: 'Spanish (Panama)' },
{ code: 'es-PY', name: 'Spanish (Paraguay)' },
{ code: 'es-PE', name: 'Spanish (Peru)' },
{ code: 'es-PR', name: 'Spanish (Puerto Rico)' },
{ code: 'es-ES', name: 'Spanish (Spain)' },
{ code: 'es-UY', name: 'Spanish (Uruguay)' },
{ code: 'es-VE', name: 'Spanish (Venezuela)' },
{ code: 'sw-KE', name: 'Swahili (Kenya)' },
{ code: 'sv-FI', name: 'Swedish (Finland)' },
{ code: 'sv-SE', name: 'Swedish (Sweden)' },
{ code: 'syr-SY', name: 'Syriac (Syria)' },
{ code: 'ta-IN', name: 'Tamil (India)' },
{ code: 'te-IN', name: 'Telugu (India)' },
{ code: 'th-TH', name: 'Thai (Thailand)' },
{ code: 'tn-ZA', name: 'Tswana (South Africa)' },
{ code: 'tr-TR', name: 'Turkish (Turkey)' },
{ code: 'uk-UA', name: 'Ukrainian (Ukraine)' },
{ code: 'uz-UZ', name: 'Uzbek (Uzbekistan)' },
{ code: 'vi-VN', name: 'Vietnamese (Vietnam)' },
{ code: 'cy-GB', name: 'Welsh (United Kingdom)' },
{ code: 'xh-ZA', name: 'Xhosa (South Africa)' },
{ code: 'zu-ZA', name: 'Zulu (South Africa)' },
];
-2
View File
@@ -1,8 +1,6 @@
import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk';
import { persisted } from 'svelte-local-storage-store';
import { writable } from 'svelte/store';
export const savedSearchTerms = persisted<string[]>('search-terms', [], {});
export const isSearchEnabled = writable<boolean>(false);
export const preventRaceConditionSearchBar = writable<boolean>(false);
export const searchQuery = writable<SmartSearchDto | MetadataSearchDto | undefined>(undefined);
+9
View File
@@ -1,6 +1,7 @@
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { NotificationType, notificationController } from '$lib/components/shared-components/notification/notification';
import { locales } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error';
import {
AssetJobName,
@@ -185,3 +186,11 @@ export const oauth = {
return unlinkOAuthAccount();
},
};
export const findLocale = (code: string | undefined) => {
const language = locales.find((lang) => lang.code === code);
return {
code: language?.code,
name: language?.name,
};
};
+4 -1
View File
@@ -1,8 +1,11 @@
import { locale } from '$lib/stores/preferences.store';
import type { AssetResponseDto } from '@immich/sdk';
import { groupBy, sortBy } from 'lodash-es';
import { DateTime, Interval } from 'luxon';
import { get } from 'svelte/store';
export const fromLocalDateTime = (localDateTime: string) => DateTime.fromISO(localDateTime, { zone: 'UTC' });
export const fromLocalDateTime = (localDateTime: string) =>
DateTime.fromISO(localDateTime, { zone: 'UTC', locale: get(locale) });
export const groupDateFormat: Intl.DateTimeFormatOptions = {
weekday: 'short',
+10 -3
View File
@@ -43,11 +43,13 @@
import { flip } from 'svelte/animate';
import type { PageData } from './$types';
import { useAlbums } from './albums.bloc';
import SearchBar from '$lib/components/elements/search-bar.svelte';
export let data: PageData;
let shouldShowEditUserForm = false;
let selectedAlbum: AlbumResponseDto;
let searchAlbum = '';
let sortByOptions: Record<string, Sort> = {
albumTitle: {
@@ -180,6 +182,8 @@
}
}
$: albumsFiltered = $albums.filter((album) => album.albumName.toLowerCase().includes(searchAlbum.toLowerCase()));
const searchSort = (searched: string): Sort => {
for (const key in sortByOptions) {
if (sortByOptions[key].title === searched) {
@@ -243,6 +247,9 @@
<UserPageLayout title={data.meta.title}>
<div class="flex place-items-center gap-2" slot="buttons">
<div class="hidden lg:block lg:w-40 xl:w-60 2xl:w-80 h-10">
<SearchBar placeholder="Search albums" bind:name={searchAlbum} isSearching={false} />
</div>
<LinkButton on:click={handleCreateAlbum}>
<div class="flex place-items-center gap-2 text-sm">
<Icon path={mdiPlusBoxOutline} size="18" />
@@ -285,7 +292,7 @@
<!-- Album Card -->
{#if $albumViewSettings.view === AlbumViewMode.Cover}
<div class="grid grid-cols-[repeat(auto-fill,minmax(14rem,1fr))]">
{#each $albums as album, index (album.id)}
{#each albumsFiltered as album, index (album.id)}
<a data-sveltekit-preload-data="hover" href="{AppRoute.ALBUMS}/{album.id}" animate:flip={{ duration: 200 }}>
<AlbumCard
preload={index < 20}
@@ -296,7 +303,7 @@
{/each}
</div>
{:else if $albumViewSettings.view === AlbumViewMode.List}
<table class="mt-5 w-full text-left">
<table class="mt-2 w-full text-left">
<thead
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-primary"
>
@@ -310,7 +317,7 @@
<tbody
class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg"
>
{#each $albums as album (album.id)}
{#each albumsFiltered as album (album.id)}
<tr
class="flex h-[50px] w-full place-items-center border-[3px] border-transparent p-2 text-center odd:bg-immich-gray even:bg-immich-bg hover:cursor-pointer hover:border-immich-primary/75 odd:dark:bg-immich-dark-gray/75 even:dark:bg-immich-dark-gray/50 dark:hover:border-immich-dark-primary/75 md:p-5"
on:click={() => goto(`${AppRoute.ALBUMS}/${album.id}`)}
@@ -603,7 +603,7 @@
<input
on:keydown={(e) => e.key === 'Enter' && titleInput.blur()}
on:blur={handleUpdateName}
class="w-[99%] border-b-2 border-transparent text-6xl text-immich-primary outline-none transition-all dark:text-immich-dark-primary {isOwned
class="w-[99%] mb-2 border-b-2 border-transparent text-6xl text-immich-primary outline-none transition-all dark:text-immich-dark-primary {isOwned
? 'hover:border-gray-400'
: 'hover:border-transparent'} bg-immich-bg focus:border-b-2 focus:border-immich-primary focus:outline-none dark:bg-immich-dark-bg dark:focus:border-immich-dark-primary dark:focus:bg-immich-dark-gray"
type="text"
@@ -616,7 +616,7 @@
<!-- ALBUM SUMMARY -->
{#if album.assetCount > 0}
<span class="my-4 flex gap-2 text-sm font-medium text-gray-500" data-testid="album-details">
<span class="my-2 flex gap-2 text-sm font-medium text-gray-500" data-testid="album-details">
<p class="">{getDateRange()}</p>
<p>·</p>
<p>{album.assetCount} items</p>
@@ -625,7 +625,7 @@
<!-- ALBUM SHARING -->
{#if album.sharedUsers.length > 0 || (album.hasSharedLink && isOwned)}
<div class="my-6 flex gap-x-1">
<div class="my-3 flex gap-x-1">
<!-- link -->
{#if album.hasSharedLink && isOwned}
<CircleIconButton
@@ -664,7 +664,7 @@
<!-- ALBUM DESCRIPTION -->
{#if isOwned}
<textarea
class="w-full resize-none overflow-hidden text-black dark:text-white border-b-2 border-transparent border-gray-500 bg-transparent text-base outline-none transition-all focus:border-b-2 focus:border-immich-primary disabled:border-none dark:focus:border-immich-dark-primary hover:border-gray-400"
class="w-full mt-2 resize-none overflow-hidden text-black dark:text-white border-b-2 border-transparent border-gray-500 bg-transparent text-base outline-none transition-all focus:border-b-2 focus:border-immich-primary disabled:border-none dark:focus:border-immich-dark-primary hover:border-gray-400"
bind:this={textArea}
bind:value={description}
on:input={() => autoGrowHeight(textArea)}
+20 -8
View File
@@ -8,7 +8,7 @@
import Icon from '$lib/components/elements/icon.svelte';
import MergeSuggestionModal from '$lib/components/faces-page/merge-suggestion-modal.svelte';
import PeopleCard from '$lib/components/faces-page/people-card.svelte';
import SearchBar from '$lib/components/faces-page/search-bar.svelte';
import SearchBar from '$lib/components/elements/search-bar.svelte';
import SetBirthDateModal from '$lib/components/faces-page/set-birth-date-modal.svelte';
import ShowHide from '$lib/components/faces-page/show-hide.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
@@ -40,11 +40,13 @@
import { mdiAccountOff, mdiEyeOutline } from '@mdi/js';
import { onDestroy, onMount } from 'svelte';
import type { PageData } from './$types';
import { locale } from '$lib/stores/preferences.store';
export let data: PageData;
let people = data.people.people;
let countTotalPeople = data.people.total;
let countHiddenPeople = data.people.hidden;
let selectHidden = false;
let initialHiddenValues: Record<string, boolean> = {};
@@ -75,7 +77,7 @@
$: searchedPeopleLocal = searchName ? searchNameLocal(searchName, searchedPeople, maximumLengthSearchPeople) : [];
$: countVisiblePeople = people.filter((person) => !person.isHidden).length;
$: countVisiblePeople = countTotalPeople - countHiddenPeople;
const onKeyboardPress = (event: KeyboardEvent) => handleKeyboardPress(event);
@@ -152,6 +154,11 @@
for (const person of people) {
if (person.isHidden !== initialHiddenValues[person.id]) {
changed.push({ id: person.id, isHidden: person.isHidden });
if (person.isHidden) {
countHiddenPeople++;
} else {
countHiddenPeople--;
}
// Update the initial hidden values
initialHiddenValues[person.id] = person.isHidden;
@@ -203,10 +210,10 @@
const mergedPerson = await getPerson({ id: personToBeMergedIn.id });
countVisiblePeople--;
people = people.filter((person: PersonResponseDto) => person.id !== personToMerge.id);
people = people.map((person: PersonResponseDto) => (person.id === personToBeMergedIn.id ? mergedPerson : person));
countHiddenPeople--;
countTotalPeople--;
notificationController.show({
message: 'Merge people successfully',
type: NotificationType.Info,
@@ -274,7 +281,7 @@
}
showChangeNameModal = false;
countHiddenPeople++;
notificationController.show({
message: 'Changed visibility successfully',
type: NotificationType.Info,
@@ -423,7 +430,10 @@
</FullScreenModal>
{/if}
<UserPageLayout title="People" description={countTotalPeople === 0 ? undefined : `(${countTotalPeople.toString()})`}>
<UserPageLayout
title="People"
description={countVisiblePeople === 0 ? undefined : `(${countVisiblePeople.toLocaleString($locale)})`}
>
<svelte:fragment slot="buttons">
{#if countTotalPeople > 0}
<div class="flex gap-2 items-center justify-center">
@@ -431,7 +441,8 @@
<div class="w-40 lg:w-80 h-10">
<SearchBar
bind:name={searchName}
{isSearchingPeople}
isSearching={isSearchingPeople}
placeholder="Search people"
on:reset={() => {
searchedPeople = [];
}}
@@ -522,9 +533,10 @@
on:change={handleToggleVisibility}
bind:showLoadingSpinner
bind:toggleVisibility
{countTotalPeople}
screenHeight={innerHeight}
>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 xl:grid-cols-7 2xl:grid-cols-9 gap-1">
<div class="w-full grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 xl:grid-cols-7 2xl:grid-cols-9 gap-1">
{#each people as person, index (person.id)}
<button
class="relative"
+110 -138
View File
@@ -1,5 +1,4 @@
<script lang="ts">
import { browser } from '$app/environment';
import { afterNavigate, goto } from '$app/navigation';
import { page } from '$app/stores';
import AlbumCard from '$lib/components/album-page/album-card.svelte';
@@ -20,18 +19,22 @@
import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte';
import { AppRoute, QueryParameter } from '$lib/constants';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { preventRaceConditionSearchBar, searchQuery } from '$lib/stores/search.store';
import { authenticate } from '$lib/utils/auth';
import { preventRaceConditionSearchBar } from '$lib/stores/search.store';
import { shouldIgnoreShortcut } from '$lib/utils/shortcut';
import { type AssetResponseDto, type SearchResponseDto, searchSmart, searchMetadata, getPerson } from '@immich/sdk';
import {
type AssetResponseDto,
searchSmart,
searchMetadata,
getPerson,
type SmartSearchDto,
type MetadataSearchDto,
type AlbumResponseDto,
} from '@immich/sdk';
import { mdiArrowLeft, mdiDotsVertical, mdiImageOffOutline, mdiPlus, mdiSelectAll } from '@mdi/js';
import { onDestroy, onMount } from 'svelte';
import { flip } from 'svelte/animate';
import type { PageData } from './$types';
import type { Viewport } from '$lib/stores/assets.store';
import { locale } from '$lib/stores/preferences.store';
export let data: PageData;
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
const MAX_ASSET_COUNT = 5000;
let { isViewing: showAssetViewer } = assetViewingStore;
@@ -41,23 +44,14 @@
// behavior for history.back(). To prevent that we store the previous page
// manually and navigate back to that.
let previousRoute = AppRoute.EXPLORE as string;
/* eslint-disable @typescript-eslint/no-explicit-any */
let terms: any;
$: currentPage = data.results?.assets.nextPage;
$: albums = data.results?.albums.items;
let nextPage: number | null = 1;
let searchResultAlbums: AlbumResponseDto[] = [];
let searchResultAssets: AssetResponseDto[] = [];
let isLoading = true;
const onKeyboardPress = (event: KeyboardEvent) => handleKeyboardPress(event);
onMount(async () => {
document.addEventListener('keydown', onKeyboardPress);
});
onDestroy(() => {
if (browser) {
document.removeEventListener('keydown', onKeyboardPress);
}
});
const handleKeyboardPress = (event: KeyboardEvent) => {
if (shouldIgnoreShortcut(event)) {
return;
@@ -92,64 +86,61 @@
if (from?.route.id === '/(user)/albums/[albumId]') {
previousRoute = AppRoute.EXPLORE;
}
updateInformationChip();
});
let selectedAssets: Set<AssetResponseDto> = new Set();
$: isMultiSelectionMode = selectedAssets.size > 0;
$: isAllArchived = [...selectedAssets].every((asset) => asset.isArchived);
$: isAllFavorite = [...selectedAssets].every((asset) => asset.isFavorite);
$: searchResultAssets = data.results?.assets.items;
const onAssetDelete = (assetId: string) => {
searchResultAssets = searchResultAssets?.filter((a: AssetResponseDto) => a.id !== assetId);
searchResultAssets = searchResultAssets.filter((a: AssetResponseDto) => a.id !== assetId);
};
const handleSelectAll = () => {
selectedAssets = new Set(searchResultAssets);
};
function updateInformationChip() {
let query = $page.url.searchParams.get(QueryParameter.SEARCH_TERM) || data.term || '';
terms = JSON.parse(query);
type SearchTerms = MetadataSearchDto & Pick<SmartSearchDto, 'query'>;
$: searchQuery = $page.url.searchParams.get(QueryParameter.QUERY);
$: terms = ((): SearchTerms => {
return searchQuery ? JSON.parse(searchQuery) : {};
})();
$: terms, onSearchQueryUpdate();
async function onSearchQueryUpdate() {
nextPage = 1;
searchResultAssets = [];
searchResultAlbums = [];
loadNextPage();
}
export const loadNextPage = async () => {
if (currentPage == null || !terms || (searchResultAssets && searchResultAssets.length >= MAX_ASSET_COUNT)) {
if (!nextPage || searchResultAssets.length >= MAX_ASSET_COUNT) {
return;
}
isLoading = true;
await authenticate();
let results: SearchResponseDto | null = null;
$page.url.searchParams.set(QueryParameter.PAGE, currentPage.toString());
const payload = $searchQuery;
let responses: SearchResponseDto;
responses =
payload && 'query' in payload
? await searchSmart({
smartSearchDto: { ...payload, page: Number.parseInt(currentPage), withExif: true, isVisible: true },
})
: await searchMetadata({
metadataSearchDto: { ...payload, page: Number.parseInt(currentPage), withExif: true, isVisible: true },
});
if (searchResultAssets) {
searchResultAssets.push(...responses.assets.items);
} else {
searchResultAssets = responses.assets.items;
}
const assets = {
...responses.assets,
items: searchResultAssets,
};
results = {
assets,
albums: responses.albums,
const searchDto: SearchTerms = {
page: nextPage,
withExif: true,
isVisible: true,
...terms,
};
data.results = results;
const { albums, assets } =
'query' in searchDto
? await searchSmart({ smartSearchDto: searchDto })
: await searchMetadata({ metadataSearchDto: searchDto });
searchResultAlbums.push(...albums.items);
searchResultAssets.push(...assets.items);
searchResultAlbums = searchResultAlbums;
searchResultAssets = searchResultAssets;
nextPage = assets.nextPage ? Number(assets.nextPage) : null;
isLoading = false;
};
function getHumanReadableDate(date: string) {
@@ -161,51 +152,23 @@
});
}
function getHumanReadableSearchKey(key: string): string {
switch (key) {
case 'takenAfter': {
return 'Start date';
}
case 'takenBefore': {
return 'End date';
}
case 'isArchived': {
return 'In archive';
}
case 'isFavorite': {
return 'Favorite';
}
case 'isNotInAlbum': {
return 'Not in any album';
}
case 'type': {
return 'Media type';
}
case 'query': {
return 'Context';
}
case 'city': {
return 'City';
}
case 'country': {
return 'Country';
}
case 'state': {
return 'State';
}
case 'make': {
return 'Camera brand';
}
case 'model': {
return 'Camera model';
}
case 'personIds': {
return 'People';
}
default: {
return key;
}
}
function getHumanReadableSearchKey(key: keyof SearchTerms): string {
const keyMap: Partial<Record<keyof SearchTerms, string>> = {
takenAfter: 'Start date',
takenBefore: 'End date',
isArchived: 'In archive',
isFavorite: 'Favorite',
isNotInAlbum: 'Not in any album',
type: 'Media type',
query: 'Context',
city: 'City',
country: 'Country',
state: 'State',
make: 'Camera brand',
model: 'Camera model',
personIds: 'People',
};
return keyMap[key] || key;
}
async function getPersonName(personIds: string[]) {
@@ -225,8 +188,14 @@
}
const triggerAssetUpdate = () => (searchResultAssets = searchResultAssets);
function getObjectKeys<T extends object>(obj: T): (keyof T)[] {
return Object.keys(obj) as (keyof T)[];
}
</script>
<svelte:document on:keydown={onKeyboardPress} />
<section>
{#if isMultiSelectionMode}
<div class="fixed z-[100] top-0 left-0 w-full">
@@ -252,44 +221,43 @@
<div class="fixed z-[100] top-0 left-0 w-full">
<ControlAppBar on:close={() => goto(previousRoute)} backIcon={mdiArrowLeft}>
<div class="w-full flex-1 pl-4">
<SearchBar grayTheme={false} />
<SearchBar grayTheme={false} searchQuery={terms} />
</div>
</ControlAppBar>
</div>
{/if}
</section>
{#if terms}
<section
id="search-chips"
class="mt-24 text-center w-full flex gap-5 place-content-center place-items-center flex-wrap px-24"
>
{#each Object.keys(terms) as key, index (index)}
<div class="flex place-content-center place-items-center text-xs">
<div
class="bg-immich-primary py-2 px-4 text-white dark:text-black dark:bg-immich-dark-primary
{terms[key] === true ? 'rounded-full' : 'rounded-tl-full rounded-bl-full'}"
>
{getHumanReadableSearchKey(key)}
</div>
{#if terms[key] !== true}
<div class="bg-gray-300 py-2 px-4 dark:bg-gray-800 dark:text-white rounded-tr-full rounded-br-full">
{#if key === 'takenAfter' || key === 'takenBefore'}
{getHumanReadableDate(terms[key])}
{:else if key === 'personIds'}
{#await getPersonName(terms[key]) then personName}
{personName}
{/await}
{:else}
{terms[key]}
{/if}
</div>
{/if}
<section
id="search-chips"
class="mt-24 text-center w-full flex gap-5 place-content-center place-items-center flex-wrap px-24"
>
{#each getObjectKeys(terms) as key (key)}
{@const value = terms[key]}
<div class="flex place-content-center place-items-center text-xs">
<div
class="bg-immich-primary py-2 px-4 text-white dark:text-black dark:bg-immich-dark-primary
{value === true ? 'rounded-full' : 'rounded-tl-full rounded-bl-full'}"
>
{getHumanReadableSearchKey(key)}
</div>
{/each}
</section>
{/if}
{#if value !== true}
<div class="bg-gray-300 py-2 px-4 dark:bg-gray-800 dark:text-white rounded-tr-full rounded-br-full">
{#if (key === 'takenAfter' || key === 'takenBefore') && typeof value === 'string'}
{getHumanReadableDate(value)}
{:else if key === 'personIds' && Array.isArray(value)}
{#await getPersonName(value) then personName}
{personName}
{/await}
{:else}
{value}
{/if}
</div>
{/if}
</div>
{/each}
</section>
<section
class="relative mb-12 bg-immich-bg dark:bg-immich-dark-bg m-4"
@@ -297,11 +265,11 @@
bind:clientWidth={viewport.width}
>
<section class="immich-scrollbar relative overflow-y-auto">
{#if albums && albums.length > 0}
{#if searchResultAlbums.length > 0}
<section>
<div class="ml-6 text-4xl font-medium text-black/70 dark:text-white/80">ALBUMS</div>
<div class="grid grid-cols-[repeat(auto-fill,minmax(14rem,1fr))]">
{#each albums as album, index (album.id)}
{#each searchResultAlbums as album, index (album.id)}
<a data-sveltekit-preload-data="hover" href={`albums/${album.id}`} animate:flip={{ duration: 200 }}>
<AlbumCard
preload={index < 20}
@@ -318,7 +286,11 @@
</section>
{/if}
<section id="search-content" class="relative bg-immich-bg dark:bg-immich-dark-bg">
{#if searchResultAssets && searchResultAssets.length > 0}
{#if isLoading}
<div class="flex justify-center py-16 items-center">
<LoadingSpinner size="48" />
</div>
{:else if searchResultAssets.length > 0}
<GalleryViewer
assets={searchResultAssets}
bind:selectedAssets
+1 -26
View File
@@ -1,34 +1,9 @@
import { QueryParameter } from '$lib/constants';
import { searchQuery } from '$lib/stores/search.store';
import { authenticate } from '$lib/utils/auth';
import {
searchMetadata,
searchSmart,
type MetadataSearchDto,
type SearchResponseDto,
type SmartSearchDto,
} from '@immich/sdk';
import type { PageLoad } from './$types';
export const load = (async (data) => {
export const load = (async () => {
await authenticate();
const url = new URL(data.url.href);
const term =
url.searchParams.get(QueryParameter.SEARCH_TERM) || url.searchParams.get(QueryParameter.QUERY) || undefined;
let results: SearchResponseDto | null = null;
if (term) {
const payload = JSON.parse(term) as SmartSearchDto | MetadataSearchDto;
searchQuery.set(payload);
results =
payload && 'query' in payload
? await searchSmart({ smartSearchDto: { ...payload, withExif: true, isVisible: true } })
: await searchMetadata({ metadataSearchDto: { ...payload, withExif: true, isVisible: true } });
}
return {
term,
results,
meta: {
title: 'Search',
},
@@ -10,7 +10,7 @@
import OAuthSettings from '$lib/components/admin-page/settings/oauth/oauth-settings.svelte';
import PasswordLoginSettings from '$lib/components/admin-page/settings/password-login/password-login-settings.svelte';
import ServerSettings from '$lib/components/admin-page/settings/server/server-settings.svelte';
import SettingAccordion from '$lib/components/admin-page/settings/setting-accordion.svelte';
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import StorageTemplateSettings from '$lib/components/admin-page/settings/storage-template/storage-template-settings.svelte';
import ThemeSettings from '$lib/components/admin-page/settings/theme/theme-settings.svelte';
import ThumbnailSettings from '$lib/components/admin-page/settings/thumbnail/thumbnail-settings.svelte';