1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

feat(server, web): album orders (#7819)

* feat: album orders

* fix: tests

* pr feedback

* pr feedback

* pr feedback

* fix: tests

* add comment

* pr feedback

* fix: rendering issue

* wording

* fix: order value doesn't change

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
martin 2024-03-14 17:45:03 +01:00 committed by GitHub
parent 1c4637cb43
commit 31f7e1aca3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 171 additions and 33 deletions

View File

@ -1,6 +1,7 @@
import { import {
AlbumResponseDto, AlbumResponseDto,
AssetFileUploadResponseDto, AssetFileUploadResponseDto,
AssetOrder,
LoginResponseDto, LoginResponseDto,
SharedLinkType, SharedLinkType,
deleteUser, deleteUser,
@ -353,6 +354,7 @@ describe('/album', () => {
assetCount: 0, assetCount: 0,
owner: expect.objectContaining({ email: user1.userEmail }), owner: expect.objectContaining({ email: user1.userEmail }),
isActivityEnabled: true, isActivityEnabled: true,
order: AssetOrder.Desc,
}); });
}); });
}); });

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1765,6 +1765,14 @@
"type": "string" "type": "string"
} }
}, },
{
"name": "order",
"required": false,
"in": "query",
"schema": {
"$ref": "#/components/schemas/AssetOrder"
}
},
{ {
"name": "personId", "name": "personId",
"required": false, "required": false,
@ -1901,6 +1909,14 @@
"type": "string" "type": "string"
} }
}, },
{
"name": "order",
"required": false,
"in": "query",
"schema": {
"$ref": "#/components/schemas/AssetOrder"
}
},
{ {
"name": "personId", "name": "personId",
"required": false, "required": false,
@ -6722,6 +6738,9 @@
"format": "date-time", "format": "date-time",
"type": "string" "type": "string"
}, },
"order": {
"$ref": "#/components/schemas/AssetOrder"
},
"owner": { "owner": {
"$ref": "#/components/schemas/UserResponseDto" "$ref": "#/components/schemas/UserResponseDto"
}, },
@ -10335,6 +10354,9 @@
}, },
"isActivityEnabled": { "isActivityEnabled": {
"type": "boolean" "type": "boolean"
},
"order": {
"$ref": "#/components/schemas/AssetOrder"
} }
}, },
"type": "object" "type": "object"

View File

@ -153,6 +153,7 @@ export type AlbumResponseDto = {
id: string; id: string;
isActivityEnabled: boolean; isActivityEnabled: boolean;
lastModifiedAssetTimestamp?: string; lastModifiedAssetTimestamp?: string;
order?: AssetOrder;
owner: UserResponseDto; owner: UserResponseDto;
ownerId: string; ownerId: string;
shared: boolean; shared: boolean;
@ -176,6 +177,7 @@ export type UpdateAlbumDto = {
albumThumbnailAssetId?: string; albumThumbnailAssetId?: string;
description?: string; description?: string;
isActivityEnabled?: boolean; isActivityEnabled?: boolean;
order?: AssetOrder;
}; };
export type BulkIdsDto = { export type BulkIdsDto = {
ids: string[]; ids: string[];
@ -1453,12 +1455,13 @@ export function getAssetThumbnail({ format, id, key }: {
...opts ...opts
})); }));
} }
export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, personId, size, timeBucket, userId, withPartners, withStacked }: { export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, timeBucket, userId, withPartners, withStacked }: {
albumId?: string; albumId?: string;
isArchived?: boolean; isArchived?: boolean;
isFavorite?: boolean; isFavorite?: boolean;
isTrashed?: boolean; isTrashed?: boolean;
key?: string; key?: string;
order?: AssetOrder;
personId?: string; personId?: string;
size: TimeBucketSize; size: TimeBucketSize;
timeBucket: string; timeBucket: string;
@ -1475,6 +1478,7 @@ export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key,
isFavorite, isFavorite,
isTrashed, isTrashed,
key, key,
order,
personId, personId,
size, size,
timeBucket, timeBucket,
@ -1485,12 +1489,13 @@ export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key,
...opts ...opts
})); }));
} }
export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key, personId, size, userId, withPartners, withStacked }: { export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, userId, withPartners, withStacked }: {
albumId?: string; albumId?: string;
isArchived?: boolean; isArchived?: boolean;
isFavorite?: boolean; isFavorite?: boolean;
isTrashed?: boolean; isTrashed?: boolean;
key?: string; key?: string;
order?: AssetOrder;
personId?: string; personId?: string;
size: TimeBucketSize; size: TimeBucketSize;
userId?: string; userId?: string;
@ -1506,6 +1511,7 @@ export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key
isFavorite, isFavorite,
isTrashed, isTrashed,
key, key,
order,
personId, personId,
size, size,
userId, userId,
@ -2747,6 +2753,10 @@ export enum AssetTypeEnum {
Audio = "AUDIO", Audio = "AUDIO",
Other = "OTHER" Other = "OTHER"
} }
export enum AssetOrder {
Asc = "asc",
Desc = "desc"
}
export enum Error { export enum Error {
Duplicate = "duplicate", Duplicate = "duplicate",
NoPermission = "no_permission", NoPermission = "no_permission",
@ -2774,10 +2784,6 @@ export enum TimeBucketSize {
Day = "DAY", Day = "DAY",
Month = "MONTH" Month = "MONTH"
} }
export enum AssetOrder {
Asc = "asc",
Desc = "desc"
}
export enum EntityType { export enum EntityType {
Asset = "ASSET", Asset = "ASSET",
Album = "ALBUM" Album = "ALBUM"

View File

@ -1,4 +1,5 @@
import { AlbumEntity } from '@app/infra/entities'; import { AlbumEntity, AssetOrder } from '@app/infra/entities';
import { Optional } from '@nestjs/common';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { AssetResponseDto, mapAsset } from '../asset'; import { AssetResponseDto, mapAsset } from '../asset';
import { AuthDto } from '../auth/auth.dto'; import { AuthDto } from '../auth/auth.dto';
@ -23,6 +24,9 @@ export class AlbumResponseDto {
startDate?: Date; startDate?: Date;
endDate?: Date; endDate?: Date;
isActivityEnabled!: boolean; isActivityEnabled!: boolean;
@Optional()
@ApiProperty({ enumName: 'AssetOrder', enum: AssetOrder })
order?: AssetOrder;
} }
export const mapAlbum = (entity: AlbumEntity, withAssets: boolean, auth?: AuthDto): AlbumResponseDto => { export const mapAlbum = (entity: AlbumEntity, withAssets: boolean, auth?: AuthDto): AlbumResponseDto => {
@ -63,6 +67,7 @@ export const mapAlbum = (entity: AlbumEntity, withAssets: boolean, auth?: AuthDt
assets: (withAssets ? assets : []).map((asset) => mapAsset(asset, { auth })), assets: (withAssets ? assets : []).map((asset) => mapAsset(asset, { auth })),
assetCount: entity.assets?.length || 0, assetCount: entity.assets?.length || 0,
isActivityEnabled: entity.isActivityEnabled, isActivityEnabled: entity.isActivityEnabled,
order: entity.order,
}; };
}; };

View File

@ -148,6 +148,7 @@ export class AlbumService {
description: dto.description, description: dto.description,
albumThumbnailAssetId: dto.albumThumbnailAssetId, albumThumbnailAssetId: dto.albumThumbnailAssetId,
isActivityEnabled: dto.isActivityEnabled, isActivityEnabled: dto.isActivityEnabled,
order: dto.order,
}); });
return mapAlbumWithoutAssets(updatedAlbum); return mapAlbumWithoutAssets(updatedAlbum);

View File

@ -1,4 +1,6 @@
import { IsString } from 'class-validator'; import { AssetOrder } from '@app/infra/entities';
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsString } from 'class-validator';
import { Optional, ValidateBoolean, ValidateUUID } from '../../domain.util'; import { Optional, ValidateBoolean, ValidateUUID } from '../../domain.util';
export class UpdateAlbumDto { export class UpdateAlbumDto {
@ -15,4 +17,9 @@ export class UpdateAlbumDto {
@ValidateBoolean({ optional: true }) @ValidateBoolean({ optional: true })
isActivityEnabled?: boolean; isActivityEnabled?: boolean;
@IsEnum(AssetOrder)
@Optional()
@ApiProperty({ enum: AssetOrder, enumName: 'AssetOrder' })
order?: AssetOrder;
} }

View File

@ -18,11 +18,6 @@ export class DeviceIdDto {
deviceId!: string; deviceId!: string;
} }
export enum AssetOrder {
ASC = 'asc',
DESC = 'desc',
}
const hasGPS = (o: { latitude: undefined; longitude: undefined }) => const hasGPS = (o: { latitude: undefined; longitude: undefined }) =>
o.latitude !== undefined || o.longitude !== undefined; o.latitude !== undefined || o.longitude !== undefined;
const ValidateGPS = () => ValidateIf(hasGPS); const ValidateGPS = () => ValidateIf(hasGPS);

View File

@ -1,6 +1,7 @@
import { AssetOrder } from '@app/infra/entities';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty, IsString } from 'class-validator'; import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
import { ValidateBoolean, ValidateUUID } from '../../domain.util'; import { Optional, ValidateBoolean, ValidateUUID } from '../../domain.util';
import { TimeBucketSize } from '../../repositories'; import { TimeBucketSize } from '../../repositories';
export class TimeBucketDto { export class TimeBucketDto {
@ -32,6 +33,11 @@ export class TimeBucketDto {
@ValidateBoolean({ optional: true }) @ValidateBoolean({ optional: true })
withPartners?: boolean; withPartners?: boolean;
@IsEnum(AssetOrder)
@Optional()
@ApiProperty({ enum: AssetOrder, enumName: 'AssetOrder' })
order?: AssetOrder;
} }
export class TimeBucketAssetDto extends TimeBucketDto { export class TimeBucketAssetDto extends TimeBucketDto {

View File

@ -1,5 +1,5 @@
import { AssetSearchOptions, ReverseGeocodeResult, SearchExploreItem } from '@app/domain'; import { AssetSearchOptions, ReverseGeocodeResult, SearchExploreItem } from '@app/domain';
import { AssetEntity, AssetJobStatusEntity, AssetType, ExifEntity } from '@app/infra/entities'; import { AssetEntity, AssetJobStatusEntity, AssetOrder, AssetType, ExifEntity } from '@app/infra/entities';
import { FindOptionsRelations, FindOptionsSelect } from 'typeorm'; import { FindOptionsRelations, FindOptionsSelect } from 'typeorm';
import { Paginated, PaginationOptions } from '../domain.util'; import { Paginated, PaginationOptions } from '../domain.util';
@ -66,6 +66,7 @@ export interface AssetBuilderOptions {
export interface TimeBucketOptions extends AssetBuilderOptions { export interface TimeBucketOptions extends AssetBuilderOptions {
size: TimeBucketSize; size: TimeBucketSize;
order?: AssetOrder;
} }
export interface TimeBucketItem { export interface TimeBucketItem {

View File

@ -1,5 +1,4 @@
import { AssetOrder } from '@app/domain/asset/dto/asset.dto'; import { AssetOrder, AssetType, GeodataPlacesEntity } from '@app/infra/entities';
import { AssetType, GeodataPlacesEntity } from '@app/infra/entities';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { IsEnum, IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator'; import { IsEnum, IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator';

View File

@ -1,6 +1,6 @@
import { AssetEntity } from '@app/infra/entities'; import { AssetEntity, AssetOrder } from '@app/infra/entities';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { AssetOrder, AssetResponseDto, mapAsset } from '../asset'; import { AssetResponseDto, mapAsset } from '../asset';
import { AuthDto } from '../auth'; import { AuthDto } from '../auth';
import { PersonResponseDto } from '../person'; import { PersonResponseDto } from '../person';
import { import {

View File

@ -14,6 +14,12 @@ import { AssetEntity } from './asset.entity';
import { SharedLinkEntity } from './shared-link.entity'; import { SharedLinkEntity } from './shared-link.entity';
import { UserEntity } from './user.entity'; import { UserEntity } from './user.entity';
// ran into issues when importing the enum from `asset.dto.ts`
export enum AssetOrder {
ASC = 'asc',
DESC = 'desc',
}
@Entity('albums') @Entity('albums')
export class AlbumEntity { export class AlbumEntity {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
@ -59,4 +65,7 @@ export class AlbumEntity {
@Column({ default: true }) @Column({ default: true })
isActivityEnabled!: boolean; isActivityEnabled!: boolean;
@Column({ type: 'varchar', default: AssetOrder.DESC })
order!: AssetOrder;
} }

View File

@ -0,0 +1,14 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AscendingOrderAlbum1710182081326 implements MigrationInterface {
name = 'AscendingOrderAlbum1710182081326'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "albums" ADD "order" character varying NOT NULL DEFAULT 'desc'`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "albums" DROP COLUMN "order"`);
}
}

View File

@ -36,7 +36,7 @@ import {
Not, Not,
Repository, Repository,
} from 'typeorm'; } from 'typeorm';
import { AssetEntity, AssetJobStatusEntity, AssetType, ExifEntity, SmartInfoEntity } from '../entities'; import { AssetEntity, AssetJobStatusEntity, AssetOrder, AssetType, ExifEntity, SmartInfoEntity } from '../entities';
import { DummyValue, GenerateSql } from '../infra.util'; import { DummyValue, GenerateSql } from '../infra.util';
import { Chunked, ChunkedArray, OptionalBetween, paginate, paginatedBuilder, searchAssetBuilder } from '../infra.utils'; import { Chunked, ChunkedArray, OptionalBetween, paginate, paginatedBuilder, searchAssetBuilder } from '../infra.utils';
import { Instrumentation } from '../instrumentation'; import { Instrumentation } from '../instrumentation';
@ -607,7 +607,7 @@ export class AssetRepository implements IAssetRepository {
.select(`COUNT(asset.id)::int`, 'count') .select(`COUNT(asset.id)::int`, 'count')
.addSelect(truncated, 'timeBucket') .addSelect(truncated, 'timeBucket')
.groupBy(truncated) .groupBy(truncated)
.orderBy(truncated, 'DESC') .orderBy(truncated, options.order === AssetOrder.ASC ? 'ASC' : 'DESC')
.getRawMany(); .getRawMany();
} }
@ -620,7 +620,7 @@ export class AssetRepository implements IAssetRepository {
// First sort by the day in localtime (put it in the right bucket) // First sort by the day in localtime (put it in the right bucket)
.orderBy(truncated, 'DESC') .orderBy(truncated, 'DESC')
// and then sort by the actual time // and then sort by the actual time
.addOrderBy('asset.fileCreatedAt', 'DESC') .addOrderBy('asset.fileCreatedAt', options.order === AssetOrder.ASC ? 'ASC' : 'DESC')
.getMany() .getMany()
); );
} }

View File

@ -15,6 +15,7 @@ FROM
"AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt", "AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt",
"AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId", "AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId",
"AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled", "AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled",
"AlbumEntity"."order" AS "AlbumEntity_order",
"AlbumEntity__AlbumEntity_owner"."id" AS "AlbumEntity__AlbumEntity_owner_id", "AlbumEntity__AlbumEntity_owner"."id" AS "AlbumEntity__AlbumEntity_owner_id",
"AlbumEntity__AlbumEntity_owner"."name" AS "AlbumEntity__AlbumEntity_owner_name", "AlbumEntity__AlbumEntity_owner"."name" AS "AlbumEntity__AlbumEntity_owner_name",
"AlbumEntity__AlbumEntity_owner"."avatarColor" AS "AlbumEntity__AlbumEntity_owner_avatarColor", "AlbumEntity__AlbumEntity_owner"."avatarColor" AS "AlbumEntity__AlbumEntity_owner_avatarColor",
@ -91,6 +92,7 @@ SELECT
"AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt", "AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt",
"AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId", "AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId",
"AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled", "AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled",
"AlbumEntity"."order" AS "AlbumEntity_order",
"AlbumEntity__AlbumEntity_owner"."id" AS "AlbumEntity__AlbumEntity_owner_id", "AlbumEntity__AlbumEntity_owner"."id" AS "AlbumEntity__AlbumEntity_owner_id",
"AlbumEntity__AlbumEntity_owner"."name" AS "AlbumEntity__AlbumEntity_owner_name", "AlbumEntity__AlbumEntity_owner"."name" AS "AlbumEntity__AlbumEntity_owner_name",
"AlbumEntity__AlbumEntity_owner"."avatarColor" AS "AlbumEntity__AlbumEntity_owner_avatarColor", "AlbumEntity__AlbumEntity_owner"."avatarColor" AS "AlbumEntity__AlbumEntity_owner_avatarColor",
@ -149,6 +151,7 @@ SELECT
"AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt", "AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt",
"AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId", "AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId",
"AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled", "AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled",
"AlbumEntity"."order" AS "AlbumEntity_order",
"AlbumEntity__AlbumEntity_owner"."id" AS "AlbumEntity__AlbumEntity_owner_id", "AlbumEntity__AlbumEntity_owner"."id" AS "AlbumEntity__AlbumEntity_owner_id",
"AlbumEntity__AlbumEntity_owner"."name" AS "AlbumEntity__AlbumEntity_owner_name", "AlbumEntity__AlbumEntity_owner"."name" AS "AlbumEntity__AlbumEntity_owner_name",
"AlbumEntity__AlbumEntity_owner"."avatarColor" AS "AlbumEntity__AlbumEntity_owner_avatarColor", "AlbumEntity__AlbumEntity_owner"."avatarColor" AS "AlbumEntity__AlbumEntity_owner_avatarColor",
@ -279,6 +282,7 @@ SELECT
"AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt", "AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt",
"AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId", "AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId",
"AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled", "AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled",
"AlbumEntity"."order" AS "AlbumEntity_order",
"AlbumEntity__AlbumEntity_sharedUsers"."id" AS "AlbumEntity__AlbumEntity_sharedUsers_id", "AlbumEntity__AlbumEntity_sharedUsers"."id" AS "AlbumEntity__AlbumEntity_sharedUsers_id",
"AlbumEntity__AlbumEntity_sharedUsers"."name" AS "AlbumEntity__AlbumEntity_sharedUsers_name", "AlbumEntity__AlbumEntity_sharedUsers"."name" AS "AlbumEntity__AlbumEntity_sharedUsers_name",
"AlbumEntity__AlbumEntity_sharedUsers"."avatarColor" AS "AlbumEntity__AlbumEntity_sharedUsers_avatarColor", "AlbumEntity__AlbumEntity_sharedUsers"."avatarColor" AS "AlbumEntity__AlbumEntity_sharedUsers_avatarColor",
@ -352,6 +356,7 @@ SELECT
"AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt", "AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt",
"AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId", "AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId",
"AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled", "AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled",
"AlbumEntity"."order" AS "AlbumEntity_order",
"AlbumEntity__AlbumEntity_sharedUsers"."id" AS "AlbumEntity__AlbumEntity_sharedUsers_id", "AlbumEntity__AlbumEntity_sharedUsers"."id" AS "AlbumEntity__AlbumEntity_sharedUsers_id",
"AlbumEntity__AlbumEntity_sharedUsers"."name" AS "AlbumEntity__AlbumEntity_sharedUsers_name", "AlbumEntity__AlbumEntity_sharedUsers"."name" AS "AlbumEntity__AlbumEntity_sharedUsers_name",
"AlbumEntity__AlbumEntity_sharedUsers"."avatarColor" AS "AlbumEntity__AlbumEntity_sharedUsers_avatarColor", "AlbumEntity__AlbumEntity_sharedUsers"."avatarColor" AS "AlbumEntity__AlbumEntity_sharedUsers_avatarColor",
@ -462,6 +467,7 @@ SELECT
"AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt", "AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt",
"AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId", "AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId",
"AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled", "AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled",
"AlbumEntity"."order" AS "AlbumEntity_order",
"AlbumEntity__AlbumEntity_sharedUsers"."id" AS "AlbumEntity__AlbumEntity_sharedUsers_id", "AlbumEntity__AlbumEntity_sharedUsers"."id" AS "AlbumEntity__AlbumEntity_sharedUsers_id",
"AlbumEntity__AlbumEntity_sharedUsers"."name" AS "AlbumEntity__AlbumEntity_sharedUsers_name", "AlbumEntity__AlbumEntity_sharedUsers"."name" AS "AlbumEntity__AlbumEntity_sharedUsers_name",
"AlbumEntity__AlbumEntity_sharedUsers"."avatarColor" AS "AlbumEntity__AlbumEntity_sharedUsers_avatarColor", "AlbumEntity__AlbumEntity_sharedUsers"."avatarColor" AS "AlbumEntity__AlbumEntity_sharedUsers_avatarColor",
@ -553,6 +559,7 @@ SELECT
"AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt", "AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt",
"AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId", "AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId",
"AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled", "AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled",
"AlbumEntity"."order" AS "AlbumEntity_order",
"AlbumEntity__AlbumEntity_owner"."id" AS "AlbumEntity__AlbumEntity_owner_id", "AlbumEntity__AlbumEntity_owner"."id" AS "AlbumEntity__AlbumEntity_owner_id",
"AlbumEntity__AlbumEntity_owner"."name" AS "AlbumEntity__AlbumEntity_owner_name", "AlbumEntity__AlbumEntity_owner"."name" AS "AlbumEntity__AlbumEntity_owner_name",
"AlbumEntity__AlbumEntity_owner"."avatarColor" AS "AlbumEntity__AlbumEntity_owner_avatarColor", "AlbumEntity__AlbumEntity_owner"."avatarColor" AS "AlbumEntity__AlbumEntity_owner_avatarColor",

View File

@ -87,6 +87,7 @@ FROM
"SharedLinkEntity__SharedLinkEntity_album"."deletedAt" AS "SharedLinkEntity__SharedLinkEntity_album_deletedAt", "SharedLinkEntity__SharedLinkEntity_album"."deletedAt" AS "SharedLinkEntity__SharedLinkEntity_album_deletedAt",
"SharedLinkEntity__SharedLinkEntity_album"."albumThumbnailAssetId" AS "SharedLinkEntity__SharedLinkEntity_album_albumThumbnailAssetId", "SharedLinkEntity__SharedLinkEntity_album"."albumThumbnailAssetId" AS "SharedLinkEntity__SharedLinkEntity_album_albumThumbnailAssetId",
"SharedLinkEntity__SharedLinkEntity_album"."isActivityEnabled" AS "SharedLinkEntity__SharedLinkEntity_album_isActivityEnabled", "SharedLinkEntity__SharedLinkEntity_album"."isActivityEnabled" AS "SharedLinkEntity__SharedLinkEntity_album_isActivityEnabled",
"SharedLinkEntity__SharedLinkEntity_album"."order" AS "SharedLinkEntity__SharedLinkEntity_album_order",
"4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."id" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_id", "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."id" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_id",
"4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."deviceAssetId" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_deviceAssetId", "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."deviceAssetId" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_deviceAssetId",
"4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."ownerId" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_ownerId", "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."ownerId" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_ownerId",
@ -248,6 +249,7 @@ SELECT
"SharedLinkEntity__SharedLinkEntity_album"."deletedAt" AS "SharedLinkEntity__SharedLinkEntity_album_deletedAt", "SharedLinkEntity__SharedLinkEntity_album"."deletedAt" AS "SharedLinkEntity__SharedLinkEntity_album_deletedAt",
"SharedLinkEntity__SharedLinkEntity_album"."albumThumbnailAssetId" AS "SharedLinkEntity__SharedLinkEntity_album_albumThumbnailAssetId", "SharedLinkEntity__SharedLinkEntity_album"."albumThumbnailAssetId" AS "SharedLinkEntity__SharedLinkEntity_album_albumThumbnailAssetId",
"SharedLinkEntity__SharedLinkEntity_album"."isActivityEnabled" AS "SharedLinkEntity__SharedLinkEntity_album_isActivityEnabled", "SharedLinkEntity__SharedLinkEntity_album"."isActivityEnabled" AS "SharedLinkEntity__SharedLinkEntity_album_isActivityEnabled",
"SharedLinkEntity__SharedLinkEntity_album"."order" AS "SharedLinkEntity__SharedLinkEntity_album_order",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."id" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_id", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."id" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_id",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."name" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_name", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."name" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_name",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."avatarColor" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_avatarColor", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."avatarColor" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_avatarColor",

View File

@ -1,4 +1,4 @@
import { AlbumEntity } from '@app/infra/entities'; import { AlbumEntity, AssetOrder } from '@app/infra/entities';
import { assetStub } from './asset.stub'; import { assetStub } from './asset.stub';
import { authStub } from './auth.stub'; import { authStub } from './auth.stub';
import { userStub } from './user.stub'; import { userStub } from './user.stub';
@ -19,6 +19,7 @@ export const albumStub = {
sharedLinks: [], sharedLinks: [],
sharedUsers: [], sharedUsers: [],
isActivityEnabled: true, isActivityEnabled: true,
order: AssetOrder.DESC,
}), }),
sharedWithUser: Object.freeze<AlbumEntity>({ sharedWithUser: Object.freeze<AlbumEntity>({
id: 'album-2', id: 'album-2',
@ -35,6 +36,7 @@ export const albumStub = {
sharedLinks: [], sharedLinks: [],
sharedUsers: [userStub.user1], sharedUsers: [userStub.user1],
isActivityEnabled: true, isActivityEnabled: true,
order: AssetOrder.DESC,
}), }),
sharedWithMultiple: Object.freeze<AlbumEntity>({ sharedWithMultiple: Object.freeze<AlbumEntity>({
id: 'album-3', id: 'album-3',
@ -51,6 +53,7 @@ export const albumStub = {
sharedLinks: [], sharedLinks: [],
sharedUsers: [userStub.user1, userStub.user2], sharedUsers: [userStub.user1, userStub.user2],
isActivityEnabled: true, isActivityEnabled: true,
order: AssetOrder.DESC,
}), }),
sharedWithAdmin: Object.freeze<AlbumEntity>({ sharedWithAdmin: Object.freeze<AlbumEntity>({
id: 'album-3', id: 'album-3',
@ -67,6 +70,7 @@ export const albumStub = {
sharedLinks: [], sharedLinks: [],
sharedUsers: [userStub.admin], sharedUsers: [userStub.admin],
isActivityEnabled: true, isActivityEnabled: true,
order: AssetOrder.DESC,
}), }),
oneAsset: Object.freeze<AlbumEntity>({ oneAsset: Object.freeze<AlbumEntity>({
id: 'album-4', id: 'album-4',
@ -83,6 +87,7 @@ export const albumStub = {
sharedLinks: [], sharedLinks: [],
sharedUsers: [], sharedUsers: [],
isActivityEnabled: true, isActivityEnabled: true,
order: AssetOrder.DESC,
}), }),
twoAssets: Object.freeze<AlbumEntity>({ twoAssets: Object.freeze<AlbumEntity>({
id: 'album-4a', id: 'album-4a',
@ -99,6 +104,7 @@ export const albumStub = {
sharedLinks: [], sharedLinks: [],
sharedUsers: [], sharedUsers: [],
isActivityEnabled: true, isActivityEnabled: true,
order: AssetOrder.DESC,
}), }),
emptyWithInvalidThumbnail: Object.freeze<AlbumEntity>({ emptyWithInvalidThumbnail: Object.freeze<AlbumEntity>({
id: 'album-5', id: 'album-5',
@ -115,6 +121,7 @@ export const albumStub = {
sharedLinks: [], sharedLinks: [],
sharedUsers: [], sharedUsers: [],
isActivityEnabled: true, isActivityEnabled: true,
order: AssetOrder.DESC,
}), }),
emptyWithValidThumbnail: Object.freeze<AlbumEntity>({ emptyWithValidThumbnail: Object.freeze<AlbumEntity>({
id: 'album-5', id: 'album-5',
@ -131,6 +138,7 @@ export const albumStub = {
sharedLinks: [], sharedLinks: [],
sharedUsers: [], sharedUsers: [],
isActivityEnabled: true, isActivityEnabled: true,
order: AssetOrder.DESC,
}), }),
oneAssetInvalidThumbnail: Object.freeze<AlbumEntity>({ oneAssetInvalidThumbnail: Object.freeze<AlbumEntity>({
id: 'album-6', id: 'album-6',
@ -147,6 +155,7 @@ export const albumStub = {
sharedLinks: [], sharedLinks: [],
sharedUsers: [], sharedUsers: [],
isActivityEnabled: true, isActivityEnabled: true,
order: AssetOrder.DESC,
}), }),
oneAssetValidThumbnail: Object.freeze<AlbumEntity>({ oneAssetValidThumbnail: Object.freeze<AlbumEntity>({
id: 'album-6', id: 'album-6',
@ -163,5 +172,6 @@ export const albumStub = {
sharedLinks: [], sharedLinks: [],
sharedUsers: [], sharedUsers: [],
isActivityEnabled: true, isActivityEnabled: true,
order: AssetOrder.DESC,
}), }),
}; };

View File

@ -1,5 +1,5 @@
import { AlbumResponseDto, AssetResponseDto, ExifResponseDto, mapUser, SharedLinkResponseDto } from '@app/domain'; import { AlbumResponseDto, AssetResponseDto, ExifResponseDto, mapUser, SharedLinkResponseDto } from '@app/domain';
import { AssetType, SharedLinkEntity, SharedLinkType, UserEntity } from '@app/infra/entities'; import { AssetOrder, AssetType, SharedLinkEntity, SharedLinkType, UserEntity } from '@app/infra/entities';
import { assetStub } from './asset.stub'; import { assetStub } from './asset.stub';
import { authStub } from './auth.stub'; import { authStub } from './auth.stub';
import { libraryStub } from './library.stub'; import { libraryStub } from './library.stub';
@ -101,6 +101,7 @@ const albumResponse: AlbumResponseDto = {
assets: [], assets: [],
assetCount: 1, assetCount: 1,
isActivityEnabled: true, isActivityEnabled: true,
order: AssetOrder.DESC,
}; };
export const sharedLinkStub = { export const sharedLinkStub = {
@ -181,6 +182,7 @@ export const sharedLinkStub = {
sharedUsers: [], sharedUsers: [],
sharedLinks: [], sharedLinks: [],
isActivityEnabled: true, isActivityEnabled: true,
order: AssetOrder.DESC,
assets: [ assets: [
{ {
id: 'id_1', id: 'id_1',

View File

@ -1,22 +1,55 @@
<script lang="ts"> <script lang="ts">
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import type { AlbumResponseDto, UserResponseDto } from '@immich/sdk'; import { updateAlbumInfo, type AlbumResponseDto, type UserResponseDto, AssetOrder } from '@immich/sdk';
import { mdiClose, mdiPlus } from '@mdi/js'; import { mdiArrowDownThin, mdiArrowUpThin, mdiClose, mdiPlus } from '@mdi/js';
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte'; import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte'; import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import SettingDropdown from '../shared-components/settings/setting-dropdown.svelte';
import type { RenderedOption } from '../elements/dropdown.svelte';
import { handleError } from '$lib/utils/handle-error';
import { findKey } from 'lodash-es';
export let album: AlbumResponseDto; export let album: AlbumResponseDto;
export let order: AssetOrder | undefined;
export let user: UserResponseDto; export let user: UserResponseDto;
export let onChangeOrder: (order: AssetOrder) => void;
const options: Record<AssetOrder, RenderedOption> = {
[AssetOrder.Asc]: { icon: mdiArrowUpThin, title: 'Oldest first' },
[AssetOrder.Desc]: { icon: mdiArrowDownThin, title: 'Newest first' },
};
$: selectedOption = order ? options[order] : options[AssetOrder.Desc];
const dispatch = createEventDispatcher<{ const dispatch = createEventDispatcher<{
close: void; close: void;
toggleEnableActivity: void; toggleEnableActivity: void;
showSelectSharedUser: void; showSelectSharedUser: void;
}>(); }>();
const handleToggle = async (returnedOption: RenderedOption) => {
if (selectedOption === returnedOption) {
return;
}
let order = AssetOrder.Desc;
order = findKey(options, (option) => option === returnedOption) as AssetOrder;
try {
await updateAlbumInfo({
id: album.id,
updateAlbumDto: {
order,
},
});
onChangeOrder(order);
} catch (error) {
handleError(error, 'Error updating album order');
}
};
</script> </script>
<FullScreenModal onClose={() => dispatch('close')}> <FullScreenModal onClose={() => dispatch('close')}>
@ -34,8 +67,16 @@
<div class=" items-center justify-center p-4"> <div class=" items-center justify-center p-4">
<div class="py-2"> <div class="py-2">
<h2 class="text-gray text-sm mb-3">SHARING</h2> <h2 class="text-gray text-sm mb-2">SETTINGS</h2>
<div class="p-2"> <div class="grid p-2 gap-y-2">
{#if order}
<SettingDropdown
title="Display order"
options={Object.values(options)}
selectedOption={options[order]}
onToggle={handleToggle}
/>
{/if}
<SettingSwitch <SettingSwitch
title="Comments & likes" title="Comments & likes"
subtitle="Let others respond" subtitle="Let others respond"

View File

@ -20,7 +20,7 @@
[SlideshowNavigation.DescendingOrder]: { icon: mdiArrowDownThin, title: 'Forward' }, [SlideshowNavigation.DescendingOrder]: { icon: mdiArrowDownThin, title: 'Forward' },
}; };
export const handleToggle = (selectedOption: RenderedOption) => { const handleToggle = (selectedOption: RenderedOption) => {
for (const [key, option] of Object.entries(options)) { for (const [key, option] of Object.entries(options)) {
if (option === selectedOption) { if (option === selectedOption) {
$slideshowNavigation = key as SlideshowNavigation; $slideshowNavigation = key as SlideshowNavigation;

View File

@ -161,7 +161,10 @@ export class AssetStore {
this.assetToBucket = {}; this.assetToBucket = {};
this.albumAssets = new Set(); this.albumAssets = new Set();
const buckets = await getTimeBuckets({ ...this.options, key: getKey() }); const buckets = await getTimeBuckets({
...this.options,
key: getKey(),
});
this.initialized = true; this.initialized = true;

View File

@ -58,6 +58,7 @@
updateAlbumInfo, updateAlbumInfo,
type ActivityResponseDto, type ActivityResponseDto,
type UserResponseDto, type UserResponseDto,
AssetOrder,
} from '@immich/sdk'; } from '@immich/sdk';
import { import {
mdiArrowLeft, mdiArrowLeft,
@ -83,6 +84,7 @@
$: album = data.album; $: album = data.album;
$: albumId = album.id; $: albumId = album.id;
$: albumKey = `${albumId}_${albumOrder}`;
$: { $: {
if (!album.isActivityEnabled && $numberOfComments === 0) { if (!album.isActivityEnabled && $numberOfComments === 0) {
@ -112,8 +114,9 @@
let globalWidth: number; let globalWidth: number;
let assetGridWidth: number; let assetGridWidth: number;
let textArea: HTMLTextAreaElement; let textArea: HTMLTextAreaElement;
let albumOrder: AssetOrder | undefined = data.album.order;
$: assetStore = new AssetStore({ albumId }); $: assetStore = new AssetStore({ albumId, order: albumOrder });
const assetInteractionStore = createAssetInteractionStore(); const assetInteractionStore = createAssetInteractionStore();
const { isMultiSelectState, selectedAssets } = assetInteractionStore; const { isMultiSelectState, selectedAssets } = assetInteractionStore;
@ -512,7 +515,7 @@
style={`width:${assetGridWidth}px`} style={`width:${assetGridWidth}px`}
> >
<!-- Use key because AssetGrid can't deal with changing stores --> <!-- Use key because AssetGrid can't deal with changing stores -->
{#key albumId} {#key albumKey}
{#if viewMode === ViewMode.SELECT_ASSETS} {#if viewMode === ViewMode.SELECT_ASSETS}
<AssetGrid <AssetGrid
assetStore={timelineStore} assetStore={timelineStore}
@ -679,7 +682,9 @@
{#if viewMode === ViewMode.OPTIONS && $user} {#if viewMode === ViewMode.OPTIONS && $user}
<AlbumOptions <AlbumOptions
{album} {album}
order={albumOrder}
user={$user} user={$user}
onChangeOrder={(order) => (albumOrder = order)}
on:close={() => (viewMode = ViewMode.VIEW)} on:close={() => (viewMode = ViewMode.VIEW)}
on:toggleEnableActivity={handleToggleEnableActivity} on:toggleEnableActivity={handleToggleEnableActivity}
on:showSelectSharedUser={() => (viewMode = ViewMode.SELECT_USERS)} on:showSelectSharedUser={() => (viewMode = ViewMode.SELECT_USERS)}

View File

@ -1,5 +1,5 @@
import { faker } from '@faker-js/faker'; import { faker } from '@faker-js/faker';
import type { AlbumResponseDto } from '@immich/sdk'; import { AssetOrder, type AlbumResponseDto } from '@immich/sdk';
import { Sync } from 'factory.ts'; import { Sync } from 'factory.ts';
import { userFactory } from './user-factory'; import { userFactory } from './user-factory';
@ -18,4 +18,5 @@ export const albumFactory = Sync.makeFactory<AlbumResponseDto>({
sharedUsers: [], sharedUsers: [],
hasSharedLink: false, hasSharedLink: false,
isActivityEnabled: true, isActivityEnabled: true,
order: AssetOrder.Desc,
}); });