mirror of
https://github.com/immich-app/immich.git
synced 2026-05-23 23:42:30 -04:00
Merge branch 'main' of https://github.com/immich-app/immich into chore/admin-only-library
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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' })],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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 = {
|
||||
|
||||
Generated
+1
@@ -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
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8633,6 +8633,9 @@
|
||||
},
|
||||
"PeopleResponseDto": {
|
||||
"properties": {
|
||||
"hidden": {
|
||||
"type": "integer"
|
||||
},
|
||||
"people": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PersonResponseDto"
|
||||
@@ -8644,6 +8647,7 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"hidden",
|
||||
"people",
|
||||
"total"
|
||||
],
|
||||
|
||||
+6
@@ -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>}
|
||||
|
||||
Generated
+1
@@ -526,6 +526,7 @@ export type UpdatePartnerDto = {
|
||||
inTimeline: boolean;
|
||||
};
|
||||
export type PeopleResponseDto = {
|
||||
hidden: number;
|
||||
people: PersonResponseDto[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
+2
-2
@@ -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 \
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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[];
|
||||
},
|
||||
};
|
||||
@@ -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,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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Generated
+4
-4
@@ -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
@@ -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;
|
||||
|
||||
+7
-5
@@ -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;
|
||||
|
||||
+2
-2
@@ -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;
|
||||
|
||||
+2
-2
@@ -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;
|
||||
|
||||
+5
-3
@@ -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;
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
+5
-4
@@ -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>
|
||||
|
||||
+2
-4
@@ -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>
|
||||
+1
@@ -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
@@ -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)' },
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
Reference in New Issue
Block a user