mirror of
https://github.com/immich-app/immich.git
synced 2025-01-21 00:52:43 -05:00
fix(web): sorting options for albums (#5233)
* fix: albums * pr feedback * fix: current behavior * rename * fix: album metadatas * fix: tests * fix: e2e test * simplify * fix: cover shared links * rename function * merge main * merge main --------- Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
parent
c04340c63e
commit
3aa2927dae
9 changed files with 223 additions and 81 deletions
|
@ -58,9 +58,9 @@ describe(AlbumService.name, () => {
|
||||||
describe('getAll', () => {
|
describe('getAll', () => {
|
||||||
it('gets list of albums for auth user', async () => {
|
it('gets list of albums for auth user', async () => {
|
||||||
albumMock.getOwned.mockResolvedValue([albumStub.empty, albumStub.sharedWithUser]);
|
albumMock.getOwned.mockResolvedValue([albumStub.empty, albumStub.sharedWithUser]);
|
||||||
albumMock.getAssetCountForIds.mockResolvedValue([
|
albumMock.getMetadataForIds.mockResolvedValue([
|
||||||
{ albumId: albumStub.empty.id, assetCount: 0 },
|
{ albumId: albumStub.empty.id, assetCount: 0, startDate: undefined, endDate: undefined },
|
||||||
{ albumId: albumStub.sharedWithUser.id, assetCount: 0 },
|
{ albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: undefined, endDate: undefined },
|
||||||
]);
|
]);
|
||||||
albumMock.getInvalidThumbnail.mockResolvedValue([]);
|
albumMock.getInvalidThumbnail.mockResolvedValue([]);
|
||||||
|
|
||||||
|
@ -72,7 +72,14 @@ describe(AlbumService.name, () => {
|
||||||
|
|
||||||
it('gets list of albums that have a specific asset', async () => {
|
it('gets list of albums that have a specific asset', async () => {
|
||||||
albumMock.getByAssetId.mockResolvedValue([albumStub.oneAsset]);
|
albumMock.getByAssetId.mockResolvedValue([albumStub.oneAsset]);
|
||||||
albumMock.getAssetCountForIds.mockResolvedValue([{ albumId: albumStub.oneAsset.id, assetCount: 1 }]);
|
albumMock.getMetadataForIds.mockResolvedValue([
|
||||||
|
{
|
||||||
|
albumId: albumStub.oneAsset.id,
|
||||||
|
assetCount: 1,
|
||||||
|
startDate: new Date('1970-01-01'),
|
||||||
|
endDate: new Date('1970-01-01'),
|
||||||
|
},
|
||||||
|
]);
|
||||||
albumMock.getInvalidThumbnail.mockResolvedValue([]);
|
albumMock.getInvalidThumbnail.mockResolvedValue([]);
|
||||||
|
|
||||||
const result = await sut.getAll(authStub.admin, { assetId: albumStub.oneAsset.id });
|
const result = await sut.getAll(authStub.admin, { assetId: albumStub.oneAsset.id });
|
||||||
|
@ -83,7 +90,9 @@ describe(AlbumService.name, () => {
|
||||||
|
|
||||||
it('gets list of albums that are shared', async () => {
|
it('gets list of albums that are shared', async () => {
|
||||||
albumMock.getShared.mockResolvedValue([albumStub.sharedWithUser]);
|
albumMock.getShared.mockResolvedValue([albumStub.sharedWithUser]);
|
||||||
albumMock.getAssetCountForIds.mockResolvedValue([{ albumId: albumStub.sharedWithUser.id, assetCount: 0 }]);
|
albumMock.getMetadataForIds.mockResolvedValue([
|
||||||
|
{ albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: undefined, endDate: undefined },
|
||||||
|
]);
|
||||||
albumMock.getInvalidThumbnail.mockResolvedValue([]);
|
albumMock.getInvalidThumbnail.mockResolvedValue([]);
|
||||||
|
|
||||||
const result = await sut.getAll(authStub.admin, { shared: true });
|
const result = await sut.getAll(authStub.admin, { shared: true });
|
||||||
|
@ -94,7 +103,9 @@ describe(AlbumService.name, () => {
|
||||||
|
|
||||||
it('gets list of albums that are NOT shared', async () => {
|
it('gets list of albums that are NOT shared', async () => {
|
||||||
albumMock.getNotShared.mockResolvedValue([albumStub.empty]);
|
albumMock.getNotShared.mockResolvedValue([albumStub.empty]);
|
||||||
albumMock.getAssetCountForIds.mockResolvedValue([{ albumId: albumStub.empty.id, assetCount: 0 }]);
|
albumMock.getMetadataForIds.mockResolvedValue([
|
||||||
|
{ albumId: albumStub.empty.id, assetCount: 0, startDate: undefined, endDate: undefined },
|
||||||
|
]);
|
||||||
albumMock.getInvalidThumbnail.mockResolvedValue([]);
|
albumMock.getInvalidThumbnail.mockResolvedValue([]);
|
||||||
|
|
||||||
const result = await sut.getAll(authStub.admin, { shared: false });
|
const result = await sut.getAll(authStub.admin, { shared: false });
|
||||||
|
@ -106,7 +117,14 @@ describe(AlbumService.name, () => {
|
||||||
|
|
||||||
it('counts assets correctly', async () => {
|
it('counts assets correctly', async () => {
|
||||||
albumMock.getOwned.mockResolvedValue([albumStub.oneAsset]);
|
albumMock.getOwned.mockResolvedValue([albumStub.oneAsset]);
|
||||||
albumMock.getAssetCountForIds.mockResolvedValue([{ albumId: albumStub.oneAsset.id, assetCount: 1 }]);
|
albumMock.getMetadataForIds.mockResolvedValue([
|
||||||
|
{
|
||||||
|
albumId: albumStub.oneAsset.id,
|
||||||
|
assetCount: 1,
|
||||||
|
startDate: new Date('1970-01-01'),
|
||||||
|
endDate: new Date('1970-01-01'),
|
||||||
|
},
|
||||||
|
]);
|
||||||
albumMock.getInvalidThumbnail.mockResolvedValue([]);
|
albumMock.getInvalidThumbnail.mockResolvedValue([]);
|
||||||
|
|
||||||
const result = await sut.getAll(authStub.admin, {});
|
const result = await sut.getAll(authStub.admin, {});
|
||||||
|
@ -118,8 +136,13 @@ describe(AlbumService.name, () => {
|
||||||
|
|
||||||
it('updates the album thumbnail by listing all albums', async () => {
|
it('updates the album thumbnail by listing all albums', async () => {
|
||||||
albumMock.getOwned.mockResolvedValue([albumStub.oneAssetInvalidThumbnail]);
|
albumMock.getOwned.mockResolvedValue([albumStub.oneAssetInvalidThumbnail]);
|
||||||
albumMock.getAssetCountForIds.mockResolvedValue([
|
albumMock.getMetadataForIds.mockResolvedValue([
|
||||||
{ albumId: albumStub.oneAssetInvalidThumbnail.id, assetCount: 1 },
|
{
|
||||||
|
albumId: albumStub.oneAssetInvalidThumbnail.id,
|
||||||
|
assetCount: 1,
|
||||||
|
startDate: new Date('1970-01-01'),
|
||||||
|
endDate: new Date('1970-01-01'),
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
albumMock.getInvalidThumbnail.mockResolvedValue([albumStub.oneAssetInvalidThumbnail.id]);
|
albumMock.getInvalidThumbnail.mockResolvedValue([albumStub.oneAssetInvalidThumbnail.id]);
|
||||||
albumMock.update.mockResolvedValue(albumStub.oneAssetValidThumbnail);
|
albumMock.update.mockResolvedValue(albumStub.oneAssetValidThumbnail);
|
||||||
|
@ -134,8 +157,13 @@ describe(AlbumService.name, () => {
|
||||||
|
|
||||||
it('removes the thumbnail for an empty album', async () => {
|
it('removes the thumbnail for an empty album', async () => {
|
||||||
albumMock.getOwned.mockResolvedValue([albumStub.emptyWithInvalidThumbnail]);
|
albumMock.getOwned.mockResolvedValue([albumStub.emptyWithInvalidThumbnail]);
|
||||||
albumMock.getAssetCountForIds.mockResolvedValue([
|
albumMock.getMetadataForIds.mockResolvedValue([
|
||||||
{ albumId: albumStub.emptyWithInvalidThumbnail.id, assetCount: 1 },
|
{
|
||||||
|
albumId: albumStub.emptyWithInvalidThumbnail.id,
|
||||||
|
assetCount: 1,
|
||||||
|
startDate: new Date('1970-01-01'),
|
||||||
|
endDate: new Date('1970-01-01'),
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
albumMock.getInvalidThumbnail.mockResolvedValue([albumStub.emptyWithInvalidThumbnail.id]);
|
albumMock.getInvalidThumbnail.mockResolvedValue([albumStub.emptyWithInvalidThumbnail.id]);
|
||||||
albumMock.update.mockResolvedValue(albumStub.emptyWithValidThumbnail);
|
albumMock.update.mockResolvedValue(albumStub.emptyWithValidThumbnail);
|
||||||
|
@ -413,10 +441,18 @@ describe(AlbumService.name, () => {
|
||||||
it('should get a shared album', async () => {
|
it('should get a shared album', async () => {
|
||||||
albumMock.getById.mockResolvedValue(albumStub.oneAsset);
|
albumMock.getById.mockResolvedValue(albumStub.oneAsset);
|
||||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.oneAsset.id]));
|
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.oneAsset.id]));
|
||||||
|
albumMock.getMetadataForIds.mockResolvedValue([
|
||||||
|
{
|
||||||
|
albumId: albumStub.oneAsset.id,
|
||||||
|
assetCount: 1,
|
||||||
|
startDate: new Date('1970-01-01'),
|
||||||
|
endDate: new Date('1970-01-01'),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
await sut.get(authStub.admin, albumStub.oneAsset.id, {});
|
await sut.get(authStub.admin, albumStub.oneAsset.id, {});
|
||||||
|
|
||||||
expect(albumMock.getById).toHaveBeenCalledWith(albumStub.oneAsset.id, { withAssets: true });
|
expect(albumMock.getById).toHaveBeenCalledWith(albumStub.oneAsset.id, { withAssets: false });
|
||||||
expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith(
|
expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith(
|
||||||
authStub.admin.id,
|
authStub.admin.id,
|
||||||
new Set([albumStub.oneAsset.id]),
|
new Set([albumStub.oneAsset.id]),
|
||||||
|
@ -426,10 +462,18 @@ describe(AlbumService.name, () => {
|
||||||
it('should get a shared album via a shared link', async () => {
|
it('should get a shared album via a shared link', async () => {
|
||||||
albumMock.getById.mockResolvedValue(albumStub.oneAsset);
|
albumMock.getById.mockResolvedValue(albumStub.oneAsset);
|
||||||
accessMock.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-123']));
|
accessMock.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-123']));
|
||||||
|
albumMock.getMetadataForIds.mockResolvedValue([
|
||||||
|
{
|
||||||
|
albumId: albumStub.oneAsset.id,
|
||||||
|
assetCount: 1,
|
||||||
|
startDate: new Date('1970-01-01'),
|
||||||
|
endDate: new Date('1970-01-01'),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
await sut.get(authStub.adminSharedLink, 'album-123', {});
|
await sut.get(authStub.adminSharedLink, 'album-123', {});
|
||||||
|
|
||||||
expect(albumMock.getById).toHaveBeenCalledWith('album-123', { withAssets: true });
|
expect(albumMock.getById).toHaveBeenCalledWith('album-123', { withAssets: false });
|
||||||
expect(accessMock.album.checkSharedLinkAccess).toHaveBeenCalledWith(
|
expect(accessMock.album.checkSharedLinkAccess).toHaveBeenCalledWith(
|
||||||
authStub.adminSharedLink.sharedLinkId,
|
authStub.adminSharedLink.sharedLinkId,
|
||||||
new Set(['album-123']),
|
new Set(['album-123']),
|
||||||
|
@ -439,10 +483,18 @@ describe(AlbumService.name, () => {
|
||||||
it('should get a shared album via shared with user', async () => {
|
it('should get a shared album via shared with user', async () => {
|
||||||
albumMock.getById.mockResolvedValue(albumStub.oneAsset);
|
albumMock.getById.mockResolvedValue(albumStub.oneAsset);
|
||||||
accessMock.album.checkSharedAlbumAccess.mockResolvedValue(new Set(['album-123']));
|
accessMock.album.checkSharedAlbumAccess.mockResolvedValue(new Set(['album-123']));
|
||||||
|
albumMock.getMetadataForIds.mockResolvedValue([
|
||||||
|
{
|
||||||
|
albumId: albumStub.oneAsset.id,
|
||||||
|
assetCount: 1,
|
||||||
|
startDate: new Date('1970-01-01'),
|
||||||
|
endDate: new Date('1970-01-01'),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
await sut.get(authStub.user1, 'album-123', {});
|
await sut.get(authStub.user1, 'album-123', {});
|
||||||
|
|
||||||
expect(albumMock.getById).toHaveBeenCalledWith('album-123', { withAssets: true });
|
expect(albumMock.getById).toHaveBeenCalledWith('album-123', { withAssets: false });
|
||||||
expect(accessMock.album.checkSharedAlbumAccess).toHaveBeenCalledWith(authStub.user1.id, new Set(['album-123']));
|
expect(accessMock.album.checkSharedAlbumAccess).toHaveBeenCalledWith(authStub.user1.id, new Set(['album-123']));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,7 @@ import { AuthUserDto } from '../auth';
|
||||||
import { setUnion } from '../domain.util';
|
import { setUnion } from '../domain.util';
|
||||||
import { JobName } from '../job';
|
import { JobName } from '../job';
|
||||||
import {
|
import {
|
||||||
|
AlbumAssetCount,
|
||||||
AlbumInfoOptions,
|
AlbumInfoOptions,
|
||||||
IAccessRepository,
|
IAccessRepository,
|
||||||
IAlbumRepository,
|
IAlbumRepository,
|
||||||
|
@ -69,11 +70,19 @@ export class AlbumService {
|
||||||
|
|
||||||
// Get asset count for each album. Then map the result to an object:
|
// Get asset count for each album. Then map the result to an object:
|
||||||
// { [albumId]: assetCount }
|
// { [albumId]: assetCount }
|
||||||
const albumsAssetCount = await this.albumRepository.getAssetCountForIds(albums.map((album) => album.id));
|
const albumMetadataForIds = await this.albumRepository.getMetadataForIds(albums.map((album) => album.id));
|
||||||
const albumsAssetCountObj = albumsAssetCount.reduce((obj: Record<string, number>, { albumId, assetCount }) => {
|
const albumMetadataForIdsObj: Record<string, AlbumAssetCount> = albumMetadataForIds.reduce(
|
||||||
obj[albumId] = assetCount;
|
(obj: Record<string, AlbumAssetCount>, { albumId, assetCount, startDate, endDate }) => {
|
||||||
return obj;
|
obj[albumId] = {
|
||||||
}, {});
|
albumId,
|
||||||
|
assetCount,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
};
|
||||||
|
return obj;
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
albums.map(async (album) => {
|
albums.map(async (album) => {
|
||||||
|
@ -81,7 +90,9 @@ export class AlbumService {
|
||||||
return {
|
return {
|
||||||
...mapAlbumWithoutAssets(album),
|
...mapAlbumWithoutAssets(album),
|
||||||
sharedLinks: undefined,
|
sharedLinks: undefined,
|
||||||
assetCount: albumsAssetCountObj[album.id],
|
startDate: albumMetadataForIdsObj[album.id].startDate,
|
||||||
|
endDate: albumMetadataForIdsObj[album.id].endDate,
|
||||||
|
assetCount: albumMetadataForIdsObj[album.id].assetCount,
|
||||||
lastModifiedAssetTimestamp: lastModifiedAsset?.fileModifiedAt,
|
lastModifiedAssetTimestamp: lastModifiedAsset?.fileModifiedAt,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
@ -91,7 +102,16 @@ export class AlbumService {
|
||||||
async get(authUser: AuthUserDto, id: string, dto: AlbumInfoDto): Promise<AlbumResponseDto> {
|
async get(authUser: AuthUserDto, id: string, dto: AlbumInfoDto): Promise<AlbumResponseDto> {
|
||||||
await this.access.requirePermission(authUser, Permission.ALBUM_READ, id);
|
await this.access.requirePermission(authUser, Permission.ALBUM_READ, id);
|
||||||
await this.albumRepository.updateThumbnails();
|
await this.albumRepository.updateThumbnails();
|
||||||
return mapAlbum(await this.findOrFail(id, { withAssets: true }), !dto.withoutAssets);
|
const withAssets = dto.withoutAssets === undefined ? false : !dto.withoutAssets;
|
||||||
|
const album = await this.findOrFail(id, { withAssets });
|
||||||
|
const [albumMetadataForIds] = await this.albumRepository.getMetadataForIds([album.id]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...mapAlbum(album, withAssets),
|
||||||
|
startDate: albumMetadataForIds.startDate,
|
||||||
|
endDate: albumMetadataForIds.endDate,
|
||||||
|
assetCount: albumMetadataForIds.assetCount,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(authUser: AuthUserDto, dto: CreateAlbumDto): Promise<AlbumResponseDto> {
|
async create(authUser: AuthUserDto, dto: CreateAlbumDto): Promise<AlbumResponseDto> {
|
||||||
|
|
|
@ -5,6 +5,8 @@ export const IAlbumRepository = 'IAlbumRepository';
|
||||||
export interface AlbumAssetCount {
|
export interface AlbumAssetCount {
|
||||||
albumId: string;
|
albumId: string;
|
||||||
assetCount: number;
|
assetCount: number;
|
||||||
|
startDate: Date | undefined;
|
||||||
|
endDate: Date | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AlbumInfoOptions {
|
export interface AlbumInfoOptions {
|
||||||
|
@ -30,7 +32,7 @@ export interface IAlbumRepository {
|
||||||
hasAsset(asset: AlbumAsset): Promise<boolean>;
|
hasAsset(asset: AlbumAsset): Promise<boolean>;
|
||||||
removeAsset(assetId: string): Promise<void>;
|
removeAsset(assetId: string): Promise<void>;
|
||||||
removeAssets(assets: AlbumAssets): Promise<void>;
|
removeAssets(assets: AlbumAssets): Promise<void>;
|
||||||
getAssetCountForIds(ids: string[]): Promise<AlbumAssetCount[]>;
|
getMetadataForIds(ids: string[]): Promise<AlbumAssetCount[]>;
|
||||||
getInvalidThumbnail(): Promise<string[]>;
|
getInvalidThumbnail(): Promise<string[]>;
|
||||||
getOwned(ownerId: string): Promise<AlbumEntity[]>;
|
getOwned(ownerId: string): Promise<AlbumEntity[]>;
|
||||||
getShared(ownerId: string): Promise<AlbumEntity[]>;
|
getShared(ownerId: string): Promise<AlbumEntity[]>;
|
||||||
|
|
|
@ -59,25 +59,30 @@ export class AlbumRepository implements IAlbumRepository {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAssetCountForIds(ids: string[]): Promise<AlbumAssetCount[]> {
|
async getMetadataForIds(ids: string[]): Promise<AlbumAssetCount[]> {
|
||||||
// Guard against running invalid query when ids list is empty.
|
// Guard against running invalid query when ids list is empty.
|
||||||
if (!ids.length) {
|
if (!ids.length) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only possible with query builder because of GROUP BY.
|
// Only possible with query builder because of GROUP BY.
|
||||||
const countByAlbums = await this.repository
|
const albumMetadatas = await this.repository
|
||||||
.createQueryBuilder('album')
|
.createQueryBuilder('album')
|
||||||
.select('album.id')
|
.select('album.id')
|
||||||
.addSelect('COUNT(albums_assets.assetsId)', 'asset_count')
|
.addSelect('MIN(assets.fileCreatedAt)', 'start_date')
|
||||||
.leftJoin('albums_assets_assets', 'albums_assets', 'albums_assets.albumsId = album.id')
|
.addSelect('MAX(assets.fileCreatedAt)', 'end_date')
|
||||||
|
.addSelect('COUNT(album_assets.assetsId)', 'asset_count')
|
||||||
|
.leftJoin('albums_assets_assets', 'album_assets', 'album_assets.albumsId = album.id')
|
||||||
|
.leftJoin('assets', 'assets', 'assets.id = album_assets.assetsId')
|
||||||
.where('album.id IN (:...ids)', { ids })
|
.where('album.id IN (:...ids)', { ids })
|
||||||
.groupBy('album.id')
|
.groupBy('album.id')
|
||||||
.getRawMany();
|
.getRawMany();
|
||||||
|
|
||||||
return countByAlbums.map<AlbumAssetCount>((albumCount) => ({
|
return albumMetadatas.map<AlbumAssetCount>((metadatas) => ({
|
||||||
albumId: albumCount['album_id'],
|
albumId: metadatas['album_id'],
|
||||||
assetCount: Number(albumCount['asset_count']),
|
assetCount: Number(metadatas['asset_count']),
|
||||||
|
startDate: metadatas['end_date'] ? new Date(metadatas['start_date']) : undefined,
|
||||||
|
endDate: metadatas['end_date'] ? new Date(metadatas['end_date']) : undefined,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -246,7 +246,7 @@ describe(`${AlbumController.name} (e2e)`, () => {
|
||||||
|
|
||||||
it('should return album info for own album', async () => {
|
it('should return album info for own album', async () => {
|
||||||
const { status, body } = await request(server)
|
const { status, body } = await request(server)
|
||||||
.get(`/album/${user1Albums[0].id}`)
|
.get(`/album/${user1Albums[0].id}?withoutAssets=false`)
|
||||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||||
|
|
||||||
expect(status).toBe(200);
|
expect(status).toBe(200);
|
||||||
|
@ -255,7 +255,7 @@ describe(`${AlbumController.name} (e2e)`, () => {
|
||||||
|
|
||||||
it('should return album info for shared album', async () => {
|
it('should return album info for shared album', async () => {
|
||||||
const { status, body } = await request(server)
|
const { status, body } = await request(server)
|
||||||
.get(`/album/${user2Albums[0].id}`)
|
.get(`/album/${user2Albums[0].id}?withoutAssets=false`)
|
||||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||||
|
|
||||||
expect(status).toBe(200);
|
expect(status).toBe(200);
|
||||||
|
|
|
@ -5,7 +5,7 @@ export const newAlbumRepositoryMock = (): jest.Mocked<IAlbumRepository> => {
|
||||||
getById: jest.fn(),
|
getById: jest.fn(),
|
||||||
getByIds: jest.fn(),
|
getByIds: jest.fn(),
|
||||||
getByAssetId: jest.fn(),
|
getByAssetId: jest.fn(),
|
||||||
getAssetCountForIds: jest.fn(),
|
getMetadataForIds: jest.fn(),
|
||||||
getInvalidThumbnail: jest.fn(),
|
getInvalidThumbnail: jest.fn(),
|
||||||
getOwned: jest.fn(),
|
getOwned: jest.fn(),
|
||||||
getShared: jest.fn(),
|
getShared: jest.fn(),
|
||||||
|
|
|
@ -5,10 +5,10 @@
|
||||||
export let option: Sort;
|
export let option: Sort;
|
||||||
|
|
||||||
const handleSort = () => {
|
const handleSort = () => {
|
||||||
if (albumViewSettings === option.sortTitle) {
|
if (albumViewSettings === option.title) {
|
||||||
option.sortDesc = !option.sortDesc;
|
option.sortDesc = !option.sortDesc;
|
||||||
} else {
|
} else {
|
||||||
albumViewSettings = option.sortTitle;
|
albumViewSettings = option.title;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
@ -18,12 +18,12 @@
|
||||||
class="rounded-lg p-2 hover:bg-immich-dark-primary hover:dark:bg-immich-dark-primary/50"
|
class="rounded-lg p-2 hover:bg-immich-dark-primary hover:dark:bg-immich-dark-primary/50"
|
||||||
on:click={() => handleSort()}
|
on:click={() => handleSort()}
|
||||||
>
|
>
|
||||||
{#if albumViewSettings === option.sortTitle}
|
{#if albumViewSettings === option.title}
|
||||||
{#if option.sortDesc}
|
{#if option.sortDesc}
|
||||||
↓
|
↓
|
||||||
{:else}
|
{:else}
|
||||||
↑
|
↑
|
||||||
{/if}
|
{/if}
|
||||||
{/if}{option.table}</button
|
{/if}{option.title}</button
|
||||||
></th
|
></th
|
||||||
>
|
>
|
||||||
|
|
|
@ -7,13 +7,14 @@
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { mdiCircleEditOutline, mdiContentCopy, mdiDelete, mdiOpenInNew } from '@mdi/js';
|
import { mdiCircleEditOutline, mdiContentCopy, mdiDelete, mdiOpenInNew } from '@mdi/js';
|
||||||
|
import noThumbnailUrl from '$lib/assets/no-thumbnail.png';
|
||||||
|
|
||||||
export let link: SharedLinkResponseDto;
|
export let link: SharedLinkResponseDto;
|
||||||
|
|
||||||
let expirationCountdown: luxon.DurationObjectUnits;
|
let expirationCountdown: luxon.DurationObjectUnits;
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
const getAssetInfo = async (): Promise<AssetResponseDto> => {
|
const getThumbnail = async (): Promise<AssetResponseDto> => {
|
||||||
let assetId = '';
|
let assetId = '';
|
||||||
|
|
||||||
if (link.album?.albumThumbnailAssetId) {
|
if (link.album?.albumThumbnailAssetId) {
|
||||||
|
@ -60,18 +61,28 @@
|
||||||
class="flex w-full gap-4 border-b border-gray-200 py-4 transition-all hover:border-immich-primary dark:border-gray-600 dark:text-immich-gray dark:hover:border-immich-dark-primary"
|
class="flex w-full gap-4 border-b border-gray-200 py-4 transition-all hover:border-immich-primary dark:border-gray-600 dark:text-immich-gray dark:hover:border-immich-dark-primary"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
{#await getAssetInfo()}
|
{#if link?.album?.albumThumbnailAssetId || link.assets.length > 0}
|
||||||
<LoadingSpinner />
|
{#await getThumbnail()}
|
||||||
{:then asset}
|
<LoadingSpinner />
|
||||||
|
{:then asset}
|
||||||
|
<img
|
||||||
|
id={asset.id}
|
||||||
|
src={api.getAssetThumbnailUrl(asset.id, ThumbnailFormat.Webp)}
|
||||||
|
alt={asset.id}
|
||||||
|
class="h-[100px] w-[100px] rounded-lg object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
{/await}
|
||||||
|
{:else}
|
||||||
<img
|
<img
|
||||||
id={asset.id}
|
src={noThumbnailUrl}
|
||||||
src={api.getAssetThumbnailUrl(asset.id, ThumbnailFormat.Webp)}
|
alt={'Album without assets'}
|
||||||
alt={asset.id}
|
|
||||||
class="h-[100px] w-[100px] rounded-lg object-cover"
|
class="h-[100px] w-[100px] rounded-lg object-cover"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
draggable="false"
|
draggable="false"
|
||||||
/>
|
/>
|
||||||
{/await}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col justify-between">
|
<div class="flex flex-col justify-between">
|
||||||
|
|
|
@ -1,9 +1,6 @@
|
||||||
<script lang="ts" context="module">
|
<script lang="ts" context="module">
|
||||||
// table is the text printed in the table and sortTitle is the text printed in the dropDow menu
|
|
||||||
|
|
||||||
export interface Sort {
|
export interface Sort {
|
||||||
table: string;
|
title: string;
|
||||||
sortTitle: string;
|
|
||||||
sortDesc: boolean;
|
sortDesc: boolean;
|
||||||
widthClass: string;
|
widthClass: string;
|
||||||
sortFn: (reverse: boolean, albums: AlbumResponseDto[]) => AlbumResponseDto[];
|
sortFn: (reverse: boolean, albums: AlbumResponseDto[]) => AlbumResponseDto[];
|
||||||
|
@ -54,46 +51,75 @@
|
||||||
|
|
||||||
let sortByOptions: Record<string, Sort> = {
|
let sortByOptions: Record<string, Sort> = {
|
||||||
albumTitle: {
|
albumTitle: {
|
||||||
table: 'Album title',
|
title: 'Album title',
|
||||||
sortTitle: 'Album title',
|
|
||||||
sortDesc: $albumViewSettings.sortDesc, // Load Sort Direction
|
sortDesc: $albumViewSettings.sortDesc, // Load Sort Direction
|
||||||
widthClass: 'w-8/12 text-left sm:w-4/12 md:w-4/12 md:w-4/12 2xl:w-6/12',
|
widthClass: 'text-left w-8/12 sm:w-4/12 md:w-4/12 md:w-4/12 xl:w-[30%] 2xl:w-[40%]',
|
||||||
sortFn: (reverse, albums) => {
|
sortFn: (reverse, albums) => {
|
||||||
return orderBy(albums, 'albumName', [reverse ? 'desc' : 'asc']);
|
return orderBy(albums, 'albumName', [reverse ? 'desc' : 'asc']);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
numberOfAssets: {
|
numberOfAssets: {
|
||||||
table: 'Assets',
|
title: 'Number of assets',
|
||||||
sortTitle: 'Number of assets',
|
|
||||||
sortDesc: $albumViewSettings.sortDesc,
|
sortDesc: $albumViewSettings.sortDesc,
|
||||||
widthClass: 'w-4/12 text-center sm:w-2/12 2xl:w-1/12',
|
widthClass: 'text-center w-4/12 m:w-2/12 md:w-2/12 xl:w-[15%] 2xl:w-[12%]',
|
||||||
sortFn: (reverse, albums) => {
|
sortFn: (reverse, albums) => {
|
||||||
return orderBy(albums, 'assetCount', [reverse ? 'desc' : 'asc']);
|
return orderBy(albums, 'assetCount', [reverse ? 'desc' : 'asc']);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
lastModified: {
|
lastModified: {
|
||||||
table: 'Updated date',
|
title: 'Last modified',
|
||||||
sortTitle: 'Last modified',
|
|
||||||
sortDesc: $albumViewSettings.sortDesc,
|
sortDesc: $albumViewSettings.sortDesc,
|
||||||
widthClass: 'text-center hidden sm:block w-3/12 lg:w-2/12',
|
widthClass: 'text-center hidden sm:block w-3/12 xl:w-[15%] 2xl:w-[12%]',
|
||||||
sortFn: (reverse, albums) => {
|
sortFn: (reverse, albums) => {
|
||||||
return orderBy(albums, [(album) => new Date(album.updatedAt)], [reverse ? 'desc' : 'asc']);
|
return orderBy(albums, [(album) => new Date(album.updatedAt)], [reverse ? 'desc' : 'asc']);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mostRecent: {
|
created: {
|
||||||
table: 'Created date',
|
title: 'Created date',
|
||||||
sortTitle: 'Most recent photo',
|
|
||||||
sortDesc: $albumViewSettings.sortDesc,
|
sortDesc: $albumViewSettings.sortDesc,
|
||||||
widthClass: 'text-center hidden sm:block w-3/12 lg:w-2/12',
|
widthClass: 'text-center hidden sm:block w-3/12 xl:w-[15%] 2xl:w-[12%]',
|
||||||
|
sortFn: (reverse, albums) => {
|
||||||
|
return orderBy(albums, [(album) => new Date(album.createdAt)], [reverse ? 'desc' : 'asc']);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mostRecent: {
|
||||||
|
title: 'Most recent photo',
|
||||||
|
sortDesc: $albumViewSettings.sortDesc,
|
||||||
|
widthClass: 'text-center hidden xl:block xl:w-[15%] 2xl:w-[12%]',
|
||||||
sortFn: (reverse, albums) => {
|
sortFn: (reverse, albums) => {
|
||||||
return orderBy(
|
return orderBy(
|
||||||
albums,
|
albums,
|
||||||
[
|
[(album) => (album.endDate ? new Date(album.endDate) : '')],
|
||||||
(album) =>
|
|
||||||
album.lastModifiedAssetTimestamp ? new Date(album.lastModifiedAssetTimestamp) : new Date(album.updatedAt),
|
|
||||||
],
|
|
||||||
[reverse ? 'desc' : 'asc'],
|
[reverse ? 'desc' : 'asc'],
|
||||||
);
|
).sort((a, b) => {
|
||||||
|
if (a.endDate === undefined) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (b.endDate === undefined) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mostOld: {
|
||||||
|
title: 'Oldest photo',
|
||||||
|
sortDesc: $albumViewSettings.sortDesc,
|
||||||
|
widthClass: 'text-center hidden xl:block xl:w-[15%] 2xl:w-[12%]',
|
||||||
|
sortFn: (reverse, albums) => {
|
||||||
|
return orderBy(
|
||||||
|
albums,
|
||||||
|
[(album) => (album.startDate ? new Date(album.startDate) : null)],
|
||||||
|
[reverse ? 'desc' : 'asc'],
|
||||||
|
).sort((a, b) => {
|
||||||
|
if (a.startDate === undefined) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (b.startDate === undefined) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -144,16 +170,25 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
$: {
|
$: {
|
||||||
const { sortBy } = $albumViewSettings;
|
|
||||||
for (const key in sortByOptions) {
|
for (const key in sortByOptions) {
|
||||||
if (sortByOptions[key].sortTitle === sortBy) {
|
if (sortByOptions[key].title === $albumViewSettings.sortBy) {
|
||||||
$albums = sortByOptions[key].sortFn(sortByOptions[key].sortDesc, $unsortedAlbums);
|
$albums = sortByOptions[key].sortFn(sortByOptions[key].sortDesc, $unsortedAlbums);
|
||||||
$albumViewSettings.sortDesc = sortByOptions[key].sortDesc; // "Save" sortDesc
|
$albumViewSettings.sortDesc = sortByOptions[key].sortDesc; // "Save" sortDesc
|
||||||
|
$albumViewSettings.sortBy = sortByOptions[key].title;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const test = (searched: string): Sort => {
|
||||||
|
for (const key in sortByOptions) {
|
||||||
|
if (sortByOptions[key].title === searched) {
|
||||||
|
return sortByOptions[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sortByOptions[0];
|
||||||
|
};
|
||||||
|
|
||||||
const handleCreateAlbum = async () => {
|
const handleCreateAlbum = async () => {
|
||||||
const newAlbum = await createAlbum();
|
const newAlbum = await createAlbum();
|
||||||
if (newAlbum) {
|
if (newAlbum) {
|
||||||
|
@ -220,19 +255,20 @@
|
||||||
|
|
||||||
<Dropdown
|
<Dropdown
|
||||||
options={Object.values(sortByOptions)}
|
options={Object.values(sortByOptions)}
|
||||||
|
selectedOption={test($albumViewSettings.sortBy)}
|
||||||
render={(option) => {
|
render={(option) => {
|
||||||
return {
|
return {
|
||||||
title: option.sortTitle,
|
title: option.title,
|
||||||
icon: option.sortDesc ? mdiArrowDownThin : mdiArrowUpThin,
|
icon: option.sortDesc ? mdiArrowDownThin : mdiArrowUpThin,
|
||||||
};
|
};
|
||||||
}}
|
}}
|
||||||
on:select={(event) => {
|
on:select={(event) => {
|
||||||
for (const key in sortByOptions) {
|
for (const key in sortByOptions) {
|
||||||
if (sortByOptions[key].sortTitle === event.detail.sortTitle) {
|
if (sortByOptions[key].title === event.detail.title) {
|
||||||
sortByOptions[key].sortDesc = !sortByOptions[key].sortDesc;
|
sortByOptions[key].sortDesc = !sortByOptions[key].sortDesc;
|
||||||
|
$albumViewSettings.sortBy = sortByOptions[key].title;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$albumViewSettings.sortBy = event.detail.sortTitle;
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
@ -271,7 +307,7 @@
|
||||||
{#each Object.keys(sortByOptions) as key (key)}
|
{#each Object.keys(sortByOptions) as key (key)}
|
||||||
<TableHeader bind:albumViewSettings={$albumViewSettings.sortBy} bind:option={sortByOptions[key]} />
|
<TableHeader bind:albumViewSettings={$albumViewSettings.sortBy} bind:option={sortByOptions[key]} />
|
||||||
{/each}
|
{/each}
|
||||||
<th class="hidden w-2/12 text-center text-sm font-medium lg:block 2xl:w-1/12">Action</th>
|
<th class="hidden text-center text-sm font-medium 2xl:block 2xl:w-[12%]">Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody
|
<tbody
|
||||||
|
@ -284,18 +320,34 @@
|
||||||
on:keydown={(event) => event.key === 'Enter' && goto(`albums/${album.id}`)}
|
on:keydown={(event) => event.key === 'Enter' && goto(`albums/${album.id}`)}
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
<td class="text-md w-8/12 text-ellipsis text-left sm:w-4/12 md:w-4/12 2xl:w-6/12">{album.albumName}</td>
|
<td class="text-md text-ellipsis text-left w-8/12 sm:w-4/12 md:w-4/12 xl:w-[30%] 2xl:w-[40%]"
|
||||||
<td class="text-md w-4/12 text-ellipsis text-center sm:w-2/12 md:w-2/12 2xl:w-1/12">
|
>{album.albumName}</td
|
||||||
{album.assetCount}
|
|
||||||
{album.assetCount == 1 ? `item` : `items`}
|
|
||||||
</td>
|
|
||||||
<td class="text-md hidden w-3/12 text-ellipsis text-center sm:block lg:w-2/12"
|
|
||||||
>{dateLocaleString(album.updatedAt)}</td
|
|
||||||
>
|
>
|
||||||
<td class="text-md hidden w-3/12 text-ellipsis text-center sm:block lg:w-2/12"
|
<td class="text-md text-ellipsis text-center sm:w-2/12 md:w-2/12 xl:w-[15%] 2xl:w-[12%]">
|
||||||
|
{album.assetCount}
|
||||||
|
{album.assetCount > 1 ? `items` : `item`}
|
||||||
|
</td>
|
||||||
|
<td class="text-md hidden text-ellipsis text-center sm:block w-3/12 xl:w-[15%] 2xl:w-[12%]"
|
||||||
|
>{dateLocaleString(album.updatedAt)}
|
||||||
|
</td>
|
||||||
|
<td class="text-md hidden text-ellipsis text-center sm:block w-3/12 xl:w-[15%] 2xl:w-[12%]"
|
||||||
>{dateLocaleString(album.createdAt)}</td
|
>{dateLocaleString(album.createdAt)}</td
|
||||||
>
|
>
|
||||||
<td class="text-md hidden w-2/12 text-ellipsis text-center lg:block 2xl:w-1/12">
|
<td class="text-md text-ellipsis text-center hidden xl:block xl:w-[15%] 2xl:w-[12%]">
|
||||||
|
{#if album.endDate}
|
||||||
|
{dateLocaleString(album.endDate)}
|
||||||
|
{:else}
|
||||||
|
❌
|
||||||
|
{/if}</td
|
||||||
|
>
|
||||||
|
<td class="text-md text-ellipsis text-center hidden xl:block xl:w-[15%] 2xl:w-[12%]"
|
||||||
|
>{#if album.startDate}
|
||||||
|
{dateLocaleString(album.startDate)}
|
||||||
|
{:else}
|
||||||
|
❌
|
||||||
|
{/if}</td
|
||||||
|
>
|
||||||
|
<td class="text-md hidden text-ellipsis text-center 2xl:block xl:w-[15%] 2xl:w-[12%]">
|
||||||
<button
|
<button
|
||||||
on:click|stopPropagation={() => handleEdit(album)}
|
on:click|stopPropagation={() => handleEdit(album)}
|
||||||
class="rounded-full bg-immich-primary p-3 text-gray-100 transition-all duration-150 hover:bg-immich-primary/75 dark:bg-immich-dark-primary dark:text-gray-700"
|
class="rounded-full bg-immich-primary p-3 text-gray-100 transition-all duration-150 hover:bg-immich-primary/75 dark:bg-immich-dark-primary dark:text-gray-700"
|
||||||
|
|
Loading…
Add table
Reference in a new issue