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

feat (server, web): Implement Archive (#2225)

* feat (server, web): add archive

* chore: generate api

* feat (web): add empty placeholder for archive page

* chore: remove title on favorites page

Duplicates sidebar selection. Two pages (Archive and Favorites)
are consistent now

* refactor (web): create EmptyPlaceholder component for empty pages

* fixed menu close button not close:

* fix (web): remove not necessary store call

* test (web): simplify asset tests code

* test (web): simplify asset tests code

* chore (server): remove isArchived while uploading

* chore (server): remove isArchived from typesense schema

* chore: generate api

* fix (web): delete asset from archive page

* chore: change archive asset count endpoint

old endpoint: /asset/archived-count-by-user-id
new endpoint: /asset/stat/archive

* chore: generate api

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
Sergey Kondrikov 2023-04-12 18:37:52 +03:00 committed by GitHub
parent eb9481b668
commit d314805caf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 672 additions and 75 deletions

BIN
mobile/openapi/README.md generated

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.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -36,6 +36,7 @@ export interface IAssetRepository {
getSearchPropertiesByUserId(userId: string): Promise<SearchPropertiesDto[]>;
getAssetCountByTimeBucket(userId: string, timeBucket: TimeGroupEnum): Promise<AssetCountByTimeBucket[]>;
getAssetCountByUserId(userId: string): Promise<AssetCountByUserIdResponseDto>;
getArchivedAssetCountByUserId(userId: string): Promise<AssetCountByUserIdResponseDto>;
getAssetByTimeBucket(userId: string, getAssetByTimeBucketDto: GetAssetByTimeBucketDto): Promise<AssetEntity[]>;
getAssetByChecksum(userId: string, checksum: Buffer): Promise<AssetEntity>;
getExistingAssets(
@ -83,26 +84,22 @@ export class AssetRepository implements IAssetRepository {
.groupBy('asset.type')
.getRawMany();
const assetCountByUserId = new AssetCountByUserIdResponseDto();
return this.getAssetCount(items);
}
// asset type to dto property mapping
const map: Record<AssetType, keyof AssetCountByUserIdResponseDto> = {
[AssetType.AUDIO]: 'audio',
[AssetType.IMAGE]: 'photos',
[AssetType.VIDEO]: 'videos',
[AssetType.OTHER]: 'other',
};
async getArchivedAssetCountByUserId(ownerId: string): Promise<AssetCountByUserIdResponseDto> {
// Get archived asset count by AssetType
const items = await this.assetRepository
.createQueryBuilder('asset')
.select(`COUNT(asset.id)`, 'count')
.addSelect(`asset.type`, 'type')
.where('"ownerId" = :ownerId', { ownerId: ownerId })
.andWhere('asset.isVisible = true')
.andWhere('asset.isArchived = true')
.groupBy('asset.type')
.getRawMany();
for (const item of items) {
const count = Number(item.count) || 0;
const assetType = item.type as AssetType;
const type = map[assetType];
assetCountByUserId[type] = count;
assetCountByUserId.total += count;
}
return assetCountByUserId;
return this.getAssetCount(items);
}
async getAssetByTimeBucket(userId: string, getAssetByTimeBucketDto: GetAssetByTimeBucketDto): Promise<AssetEntity[]> {
@ -115,6 +112,7 @@ export class AssetRepository implements IAssetRepository {
})
.andWhere('asset.resizePath is not NULL')
.andWhere('asset.isVisible = true')
.andWhere('asset.isArchived = false')
.orderBy('asset.fileCreatedAt', 'DESC')
.getMany();
}
@ -130,6 +128,7 @@ export class AssetRepository implements IAssetRepository {
.where('"ownerId" = :userId', { userId: userId })
.andWhere('asset.resizePath is not NULL')
.andWhere('asset.isVisible = true')
.andWhere('asset.isArchived = false')
.groupBy(`date_trunc('month', "fileCreatedAt")`)
.orderBy(`date_trunc('month', "fileCreatedAt")`, 'DESC')
.getRawMany();
@ -141,6 +140,7 @@ export class AssetRepository implements IAssetRepository {
.where('"ownerId" = :userId', { userId: userId })
.andWhere('asset.resizePath is not NULL')
.andWhere('asset.isVisible = true')
.andWhere('asset.isArchived = false')
.groupBy(`date_trunc('day', "fileCreatedAt")`)
.orderBy(`date_trunc('day', "fileCreatedAt")`, 'DESC')
.getRawMany();
@ -224,6 +224,7 @@ export class AssetRepository implements IAssetRepository {
resizePath: Not(IsNull()),
isVisible: true,
isFavorite: dto.isFavorite,
isArchived: dto.isArchived,
},
relations: {
exifInfo: true,
@ -260,6 +261,7 @@ export class AssetRepository implements IAssetRepository {
*/
async update(userId: string, asset: AssetEntity, dto: UpdateAssetDto): Promise<AssetEntity> {
asset.isFavorite = dto.isFavorite ?? asset.isFavorite;
asset.isArchived = dto.isArchived ?? asset.isArchived;
if (dto.tagIds) {
const tags = await this._tagRepository.getByIds(userId, dto.tagIds);
@ -330,4 +332,27 @@ export class AssetRepository implements IAssetRepository {
},
});
}
private getAssetCount(items: any): AssetCountByUserIdResponseDto {
const assetCountByUserId = new AssetCountByUserIdResponseDto();
// asset type to dto property mapping
const map: Record<AssetType, keyof AssetCountByUserIdResponseDto> = {
[AssetType.AUDIO]: 'audio',
[AssetType.IMAGE]: 'photos',
[AssetType.VIDEO]: 'videos',
[AssetType.OTHER]: 'other',
};
for (const item of items) {
const count = Number(item.count) || 0;
const assetType = item.type as AssetType;
const type = map[assetType];
assetCountByUserId[type] = count;
assetCountByUserId.total += count;
}
return assetCountByUserId;
}
}

View File

@ -228,6 +228,11 @@ export class AssetController {
return this.assetService.getAssetCountByUserId(authUser);
}
@Authenticated()
@Get('/stat/archive')
async getArchivedAssetCountByUserId(@GetAuthUser() authUser: AuthUserDto): Promise<AssetCountByUserIdResponseDto> {
return this.assetService.getArchivedAssetCountByUserId(authUser);
}
/**
* Get all AssetEntity belong to the user
*/

View File

@ -28,6 +28,7 @@ export class AssetCore {
type: dto.assetType,
isFavorite: dto.isFavorite,
isArchived: dto.isArchived ?? false,
duration: dto.duration || null,
isVisible: dto.isVisible ?? true,
livePhotoVideo: livePhotoAssetId != null ? ({ id: livePhotoAssetId } as AssetEntity) : null,

View File

@ -32,6 +32,7 @@ const _getCreateAssetDto = (): CreateAssetDto => {
createAssetDto.fileCreatedAt = '2022-06-19T23:41:36.910Z';
createAssetDto.fileModifiedAt = '2022-06-19T23:41:36.910Z';
createAssetDto.isFavorite = false;
createAssetDto.isArchived = false;
createAssetDto.duration = '0:00:00.000000';
return createAssetDto;
@ -51,6 +52,7 @@ const _getAsset_1 = () => {
asset_1.fileCreatedAt = '2022-06-19T23:41:36.910Z';
asset_1.updatedAt = '2022-06-19T23:41:36.910Z';
asset_1.isFavorite = false;
asset_1.isArchived = false;
asset_1.mimeType = 'image/jpeg';
asset_1.webpPath = '';
asset_1.encodedVideoPath = '';
@ -72,6 +74,7 @@ const _getAsset_2 = () => {
asset_2.fileCreatedAt = '2022-06-19T23:41:36.910Z';
asset_2.updatedAt = '2022-06-19T23:41:36.910Z';
asset_2.isFavorite = false;
asset_2.isArchived = false;
asset_2.mimeType = 'image/jpeg';
asset_2.webpPath = '';
asset_2.encodedVideoPath = '';
@ -105,6 +108,15 @@ const _getAssetCountByUserId = (): AssetCountByUserIdResponseDto => {
return result;
};
const _getArchivedAssetsCountByUserId = (): AssetCountByUserIdResponseDto => {
const result = new AssetCountByUserIdResponseDto();
result.videos = 1;
result.photos = 2;
return result;
};
describe('AssetService', () => {
let sut: AssetService;
let a: Repository<AssetEntity>; // TO BE DELETED AFTER FINISHED REFACTORING
@ -136,6 +148,7 @@ describe('AssetService', () => {
getAssetByTimeBucket: jest.fn(),
getAssetByChecksum: jest.fn(),
getAssetCountByUserId: jest.fn(),
getArchivedAssetCountByUserId: jest.fn(),
getExistingAssets: jest.fn(),
countByIdAndUser: jest.fn(),
};
@ -350,14 +363,16 @@ describe('AssetService', () => {
it('get asset count by user id', async () => {
const assetCount = _getAssetCountByUserId();
assetRepositoryMock.getAssetCountByUserId.mockResolvedValue(assetCount);
assetRepositoryMock.getAssetCountByUserId.mockImplementation(() =>
Promise.resolve<AssetCountByUserIdResponseDto>(assetCount),
);
await expect(sut.getAssetCountByUserId(authStub.user1)).resolves.toEqual(assetCount);
});
const result = await sut.getAssetCountByUserId(authStub.user1);
it('get archived asset count by user id', async () => {
const assetCount = _getArchivedAssetsCountByUserId();
assetRepositoryMock.getArchivedAssetCountByUserId.mockResolvedValue(assetCount);
expect(result).toEqual(assetCount);
await expect(sut.getArchivedAssetCountByUserId(authStub.user1)).resolves.toEqual(assetCount);
});
describe('deleteAll', () => {

View File

@ -466,6 +466,10 @@ export class AssetService {
return this._assetRepository.getAssetCountByUserId(authUser.id);
}
getArchivedAssetCountByUserId(authUser: AuthUserDto): Promise<AssetCountByUserIdResponseDto> {
return this._assetRepository.getArchivedAssetCountByUserId(authUser.id);
}
async checkAssetsAccess(authUser: AuthUserDto, assetIds: string[], mustBeOwner = false) {
for (const assetId of assetIds) {
// Step 1: Check if asset is part of a public shared

View File

@ -9,6 +9,12 @@ export class AssetSearchDto {
@Transform(toBoolean)
isFavorite?: boolean;
@IsOptional()
@IsNotEmpty()
@IsBoolean()
@Transform(toBoolean)
isArchived?: boolean;
@IsOptional()
@IsNumber()
skip?: number;

View File

@ -24,6 +24,10 @@ export class CreateAssetDto {
@IsNotEmpty()
isFavorite!: boolean;
@IsOptional()
@IsBoolean()
isArchived?: boolean;
@IsOptional()
@IsBoolean()
isVisible?: boolean;

View File

@ -6,6 +6,10 @@ export class UpdateAssetDto {
@IsBoolean()
isFavorite?: boolean;
@IsOptional()
@IsBoolean()
isArchived?: boolean;
@IsOptional()
@IsArray()
@IsString({ each: true })

View File

@ -767,6 +767,14 @@
"type": "boolean"
}
},
{
"name": "isArchived",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "exifInfo.city",
"required": false,
@ -2282,6 +2290,36 @@
]
}
},
"/asset/stat/archive": {
"get": {
"operationId": "getArchivedAssetCountByUserId",
"description": "",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AssetCountByUserIdResponseDto"
}
}
}
}
},
"tags": [
"Asset"
],
"security": [
{
"bearer": []
},
{
"cookie": []
}
]
}
},
"/asset": {
"get": {
"operationId": "getAllAssets",
@ -2295,6 +2333,14 @@
"type": "boolean"
}
},
{
"name": "isArchived",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "skip",
"required": false,
@ -3726,6 +3772,9 @@
"isFavorite": {
"type": "boolean"
},
"isArchived": {
"type": "boolean"
},
"mimeType": {
"type": "string",
"nullable": true
@ -3771,6 +3820,7 @@
"fileModifiedAt",
"updatedAt",
"isFavorite",
"isArchived",
"mimeType",
"duration",
"webpPath"
@ -4984,6 +5034,9 @@
"isFavorite": {
"type": "boolean"
},
"isArchived": {
"type": "boolean"
},
"isVisible": {
"type": "boolean"
},
@ -5227,6 +5280,9 @@
},
"isFavorite": {
"type": "boolean"
},
"isArchived": {
"type": "boolean"
}
}
},

View File

@ -19,6 +19,7 @@ export class AssetResponseDto {
fileModifiedAt!: string;
updatedAt!: string;
isFavorite!: boolean;
isArchived!: boolean;
mimeType!: string | null;
duration!: string;
webpPath!: string | null;
@ -43,6 +44,7 @@ export function mapAsset(entity: AssetEntity): AssetResponseDto {
fileModifiedAt: entity.fileModifiedAt,
updatedAt: entity.updatedAt,
isFavorite: entity.isFavorite,
isArchived: entity.isArchived,
mimeType: entity.mimeType,
webpPath: entity.webpPath,
encodedVideoPath: entity.encodedVideoPath,
@ -68,6 +70,7 @@ export function mapAssetWithoutExif(entity: AssetEntity): AssetResponseDto {
fileModifiedAt: entity.fileModifiedAt,
updatedAt: entity.updatedAt,
isFavorite: entity.isFavorite,
isArchived: entity.isArchived,
mimeType: entity.mimeType,
webpPath: entity.webpPath,
encodedVideoPath: entity.encodedVideoPath,

View File

@ -28,6 +28,11 @@ export class SearchDto {
@Transform(toBoolean)
isFavorite?: boolean;
@IsBoolean()
@IsOptional()
@Transform(toBoolean)
isArchived?: boolean;
@IsString()
@IsNotEmpty()
@IsOptional()

View File

@ -15,6 +15,7 @@ export interface SearchFilter {
userId: string;
type?: AssetType;
isFavorite?: boolean;
isArchived?: boolean;
city?: string;
state?: string;
country?: string;

View File

@ -134,6 +134,7 @@ export const assetEntityStub = {
updatedAt: '2023-02-23T05:06:29.716Z',
mimeType: null,
isFavorite: true,
isArchived: false,
duration: null,
isVisible: true,
livePhotoVideo: null,
@ -158,6 +159,7 @@ export const assetEntityStub = {
updatedAt: '2023-02-23T05:06:29.716Z',
mimeType: null,
isFavorite: true,
isArchived: false,
duration: null,
isVisible: true,
livePhotoVideo: null,
@ -184,6 +186,7 @@ export const assetEntityStub = {
updatedAt: '2023-02-23T05:06:29.716Z',
mimeType: null,
isFavorite: true,
isArchived: false,
duration: null,
isVisible: true,
livePhotoVideo: null,
@ -355,6 +358,7 @@ const assetResponse: AssetResponseDto = {
fileCreatedAt: today.toISOString(),
updatedAt: today.toISOString(),
isFavorite: false,
isArchived: false,
mimeType: 'image/jpeg',
smartInfo: {
tags: [],
@ -591,6 +595,7 @@ export const sharedLinkStub = {
createdAt: today.toISOString(),
updatedAt: today.toISOString(),
isFavorite: false,
isArchived: false,
mimeType: 'image/jpeg',
smartInfo: {
assetId: 'id_1',

View File

@ -67,6 +67,9 @@ export class AssetEntity {
@Column({ type: 'boolean', default: false })
isFavorite!: boolean;
@Column({ type: 'boolean', default: false })
isArchived!: boolean;
@Column({ type: 'varchar', nullable: true })
mimeType!: string | null;

View File

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

View File

@ -1,6 +1,6 @@
import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections';
export const assetSchemaVersion = 4;
export const assetSchemaVersion = 5;
export const assetSchema: CollectionCreateSchema = {
name: `assets-v${assetSchemaVersion}`,
fields: [
@ -14,8 +14,6 @@ export const assetSchema: CollectionCreateSchema = {
{ name: 'fileModifiedAt', type: 'string', facet: false, sort: true },
{ name: 'isFavorite', type: 'bool', facet: true },
{ name: 'originalFileName', type: 'string', facet: false, optional: true },
// { name: 'checksum', type: 'string', facet: true },
// { name: 'tags', type: 'string[]', facet: true, optional: true },
// exif
{ name: 'exifInfo.city', type: 'string', facet: true, optional: true },

View File

@ -512,6 +512,12 @@ export interface AssetResponseDto {
* @memberof AssetResponseDto
*/
'isFavorite': boolean;
/**
*
* @type {boolean}
* @memberof AssetResponseDto
*/
'isArchived': boolean;
/**
*
* @type {string}
@ -2329,6 +2335,12 @@ export interface UpdateAssetDto {
* @memberof UpdateAssetDto
*/
'isFavorite'?: boolean;
/**
*
* @type {boolean}
* @memberof UpdateAssetDto
*/
'isArchived'?: boolean;
}
/**
*
@ -4274,12 +4286,13 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
/**
* Get all AssetEntity belong to the user
* @param {boolean} [isFavorite]
* @param {boolean} [isArchived]
* @param {number} [skip]
* @param {string} [ifNoneMatch] ETag of data already cached on the client
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getAllAssets: async (isFavorite?: boolean, skip?: number, ifNoneMatch?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
getAllAssets: async (isFavorite?: boolean, isArchived?: boolean, skip?: number, ifNoneMatch?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/asset`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@ -4302,6 +4315,10 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
localVarQueryParameter['isFavorite'] = isFavorite;
}
if (isArchived !== undefined) {
localVarQueryParameter['isArchived'] = isArchived;
}
if (skip !== undefined) {
localVarQueryParameter['skip'] = skip;
}
@ -4312,6 +4329,41 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getArchivedAssetCountByUserId: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/asset/stat/archive`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@ -4873,12 +4925,13 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
* @param {string} fileExtension
* @param {string} [key]
* @param {File} [livePhotoData]
* @param {boolean} [isArchived]
* @param {boolean} [isVisible]
* @param {string} [duration]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
uploadFile: async (assetType: AssetTypeEnum, assetData: File, deviceAssetId: string, deviceId: string, fileCreatedAt: string, fileModifiedAt: string, isFavorite: boolean, fileExtension: string, key?: string, livePhotoData?: File, isVisible?: boolean, duration?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
uploadFile: async (assetType: AssetTypeEnum, assetData: File, deviceAssetId: string, deviceId: string, fileCreatedAt: string, fileModifiedAt: string, isFavorite: boolean, fileExtension: string, key?: string, livePhotoData?: File, isArchived?: boolean, isVisible?: boolean, duration?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'assetType' is not null or undefined
assertParamExists('uploadFile', 'assetType', assetType)
// verify required parameter 'assetData' is not null or undefined
@ -4951,6 +5004,10 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
localVarFormParams.append('isFavorite', isFavorite as any);
}
if (isArchived !== undefined) {
localVarFormParams.append('isArchived', isArchived as any);
}
if (isVisible !== undefined) {
localVarFormParams.append('isVisible', isVisible as any);
}
@ -5075,13 +5132,23 @@ export const AssetApiFp = function(configuration?: Configuration) {
/**
* Get all AssetEntity belong to the user
* @param {boolean} [isFavorite]
* @param {boolean} [isArchived]
* @param {number} [skip]
* @param {string} [ifNoneMatch] ETag of data already cached on the client
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getAllAssets(isFavorite?: boolean, skip?: number, ifNoneMatch?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<AssetResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getAllAssets(isFavorite, skip, ifNoneMatch, options);
async getAllAssets(isFavorite?: boolean, isArchived?: boolean, skip?: number, ifNoneMatch?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<AssetResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getAllAssets(isFavorite, isArchived, skip, ifNoneMatch, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getArchivedAssetCountByUserId(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AssetCountByUserIdResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getArchivedAssetCountByUserId(options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
@ -5230,13 +5297,14 @@ export const AssetApiFp = function(configuration?: Configuration) {
* @param {string} fileExtension
* @param {string} [key]
* @param {File} [livePhotoData]
* @param {boolean} [isArchived]
* @param {boolean} [isVisible]
* @param {string} [duration]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async uploadFile(assetType: AssetTypeEnum, assetData: File, deviceAssetId: string, deviceId: string, fileCreatedAt: string, fileModifiedAt: string, isFavorite: boolean, fileExtension: string, key?: string, livePhotoData?: File, isVisible?: boolean, duration?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AssetFileUploadResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.uploadFile(assetType, assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, fileExtension, key, livePhotoData, isVisible, duration, options);
async uploadFile(assetType: AssetTypeEnum, assetData: File, deviceAssetId: string, deviceId: string, fileCreatedAt: string, fileModifiedAt: string, isFavorite: boolean, fileExtension: string, key?: string, livePhotoData?: File, isArchived?: boolean, isVisible?: boolean, duration?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AssetFileUploadResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.uploadFile(assetType, assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, fileExtension, key, livePhotoData, isArchived, isVisible, duration, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
}
@ -5330,13 +5398,22 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
/**
* Get all AssetEntity belong to the user
* @param {boolean} [isFavorite]
* @param {boolean} [isArchived]
* @param {number} [skip]
* @param {string} [ifNoneMatch] ETag of data already cached on the client
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getAllAssets(isFavorite?: boolean, skip?: number, ifNoneMatch?: string, options?: any): AxiosPromise<Array<AssetResponseDto>> {
return localVarFp.getAllAssets(isFavorite, skip, ifNoneMatch, options).then((request) => request(axios, basePath));
getAllAssets(isFavorite?: boolean, isArchived?: boolean, skip?: number, ifNoneMatch?: string, options?: any): AxiosPromise<Array<AssetResponseDto>> {
return localVarFp.getAllAssets(isFavorite, isArchived, skip, ifNoneMatch, options).then((request) => request(axios, basePath));
},
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getArchivedAssetCountByUserId(options?: any): AxiosPromise<AssetCountByUserIdResponseDto> {
return localVarFp.getArchivedAssetCountByUserId(options).then((request) => request(axios, basePath));
},
/**
* Get a single asset\'s information
@ -5471,13 +5548,14 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
* @param {string} fileExtension
* @param {string} [key]
* @param {File} [livePhotoData]
* @param {boolean} [isArchived]
* @param {boolean} [isVisible]
* @param {string} [duration]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
uploadFile(assetType: AssetTypeEnum, assetData: File, deviceAssetId: string, deviceId: string, fileCreatedAt: string, fileModifiedAt: string, isFavorite: boolean, fileExtension: string, key?: string, livePhotoData?: File, isVisible?: boolean, duration?: string, options?: any): AxiosPromise<AssetFileUploadResponseDto> {
return localVarFp.uploadFile(assetType, assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, fileExtension, key, livePhotoData, isVisible, duration, options).then((request) => request(axios, basePath));
uploadFile(assetType: AssetTypeEnum, assetData: File, deviceAssetId: string, deviceId: string, fileCreatedAt: string, fileModifiedAt: string, isFavorite: boolean, fileExtension: string, key?: string, livePhotoData?: File, isArchived?: boolean, isVisible?: boolean, duration?: string, options?: any): AxiosPromise<AssetFileUploadResponseDto> {
return localVarFp.uploadFile(assetType, assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, fileExtension, key, livePhotoData, isArchived, isVisible, duration, options).then((request) => request(axios, basePath));
},
};
};
@ -5586,14 +5664,25 @@ export class AssetApi extends BaseAPI {
/**
* Get all AssetEntity belong to the user
* @param {boolean} [isFavorite]
* @param {boolean} [isArchived]
* @param {number} [skip]
* @param {string} [ifNoneMatch] ETag of data already cached on the client
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof AssetApi
*/
public getAllAssets(isFavorite?: boolean, skip?: number, ifNoneMatch?: string, options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).getAllAssets(isFavorite, skip, ifNoneMatch, options).then((request) => request(this.axios, this.basePath));
public getAllAssets(isFavorite?: boolean, isArchived?: boolean, skip?: number, ifNoneMatch?: string, options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).getAllAssets(isFavorite, isArchived, skip, ifNoneMatch, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof AssetApi
*/
public getArchivedAssetCountByUserId(options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).getArchivedAssetCountByUserId(options).then((request) => request(this.axios, this.basePath));
}
/**
@ -5755,14 +5844,15 @@ export class AssetApi extends BaseAPI {
* @param {string} fileExtension
* @param {string} [key]
* @param {File} [livePhotoData]
* @param {boolean} [isArchived]
* @param {boolean} [isVisible]
* @param {string} [duration]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof AssetApi
*/
public uploadFile(assetType: AssetTypeEnum, assetData: File, deviceAssetId: string, deviceId: string, fileCreatedAt: string, fileModifiedAt: string, isFavorite: boolean, fileExtension: string, key?: string, livePhotoData?: File, isVisible?: boolean, duration?: string, options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).uploadFile(assetType, assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, fileExtension, key, livePhotoData, isVisible, duration, options).then((request) => request(this.axios, this.basePath));
public uploadFile(assetType: AssetTypeEnum, assetData: File, deviceAssetId: string, deviceId: string, fileCreatedAt: string, fileModifiedAt: string, isFavorite: boolean, fileExtension: string, key?: string, livePhotoData?: File, isArchived?: boolean, isVisible?: boolean, duration?: string, options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).uploadFile(assetType, assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, fileExtension, key, livePhotoData, isArchived, isVisible, duration, options).then((request) => request(this.axios, this.basePath));
}
}
@ -6857,6 +6947,7 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio
* @param {boolean} [clip]
* @param {'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER'} [type]
* @param {boolean} [isFavorite]
* @param {boolean} [isArchived]
* @param {string} [exifInfoCity]
* @param {string} [exifInfoState]
* @param {string} [exifInfoCountry]
@ -6869,7 +6960,7 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
search: async (q?: string, query?: string, clip?: boolean, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array<string>, smartInfoTags?: Array<string>, recent?: boolean, motion?: boolean, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
search: async (q?: string, query?: string, clip?: boolean, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, isArchived?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array<string>, smartInfoTags?: Array<string>, recent?: boolean, motion?: boolean, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/search`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@ -6908,6 +6999,10 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio
localVarQueryParameter['isFavorite'] = isFavorite;
}
if (isArchived !== undefined) {
localVarQueryParameter['isArchived'] = isArchived;
}
if (exifInfoCity !== undefined) {
localVarQueryParameter['exifInfo.city'] = exifInfoCity;
}
@ -6990,6 +7085,7 @@ export const SearchApiFp = function(configuration?: Configuration) {
* @param {boolean} [clip]
* @param {'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER'} [type]
* @param {boolean} [isFavorite]
* @param {boolean} [isArchived]
* @param {string} [exifInfoCity]
* @param {string} [exifInfoState]
* @param {string} [exifInfoCountry]
@ -7002,8 +7098,8 @@ export const SearchApiFp = function(configuration?: Configuration) {
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async search(q?: string, query?: string, clip?: boolean, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array<string>, smartInfoTags?: Array<string>, recent?: boolean, motion?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SearchResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.search(q, query, clip, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, recent, motion, options);
async search(q?: string, query?: string, clip?: boolean, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, isArchived?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array<string>, smartInfoTags?: Array<string>, recent?: boolean, motion?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SearchResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.search(q, query, clip, type, isFavorite, isArchived, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, recent, motion, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
}
@ -7039,6 +7135,7 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat
* @param {boolean} [clip]
* @param {'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER'} [type]
* @param {boolean} [isFavorite]
* @param {boolean} [isArchived]
* @param {string} [exifInfoCity]
* @param {string} [exifInfoState]
* @param {string} [exifInfoCountry]
@ -7051,8 +7148,8 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
search(q?: string, query?: string, clip?: boolean, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array<string>, smartInfoTags?: Array<string>, recent?: boolean, motion?: boolean, options?: any): AxiosPromise<SearchResponseDto> {
return localVarFp.search(q, query, clip, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, recent, motion, options).then((request) => request(axios, basePath));
search(q?: string, query?: string, clip?: boolean, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, isArchived?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array<string>, smartInfoTags?: Array<string>, recent?: boolean, motion?: boolean, options?: any): AxiosPromise<SearchResponseDto> {
return localVarFp.search(q, query, clip, type, isFavorite, isArchived, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, recent, motion, options).then((request) => request(axios, basePath));
},
};
};
@ -7091,6 +7188,7 @@ export class SearchApi extends BaseAPI {
* @param {boolean} [clip]
* @param {'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER'} [type]
* @param {boolean} [isFavorite]
* @param {boolean} [isArchived]
* @param {string} [exifInfoCity]
* @param {string} [exifInfoState]
* @param {string} [exifInfoCountry]
@ -7104,8 +7202,8 @@ export class SearchApi extends BaseAPI {
* @throws {RequiredError}
* @memberof SearchApi
*/
public search(q?: string, query?: string, clip?: boolean, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array<string>, smartInfoTags?: Array<string>, recent?: boolean, motion?: boolean, options?: AxiosRequestConfig) {
return SearchApiFp(this.configuration).search(q, query, clip, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, recent, motion, options).then((request) => request(this.axios, this.basePath));
public search(q?: string, query?: string, clip?: boolean, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, isArchived?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array<string>, smartInfoTags?: Array<string>, recent?: boolean, motion?: boolean, options?: AxiosRequestConfig) {
return SearchApiFp(this.configuration).search(q, query, clip, type, isFavorite, isArchived, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, recent, motion, options).then((request) => request(this.axios, this.basePath));
}
}

View File

@ -0,0 +1,28 @@
<script lang="ts">
import empty1Url from '$lib/assets/empty-1.svg';
export let actionHandler: undefined | (() => Promise<void>) = undefined;
export let text = '';
export let alt = '';
let hoverClasses =
'hover:bg-immich-primary/5 dark:hover:bg-immich-dark-primary/25 hover:cursor-pointer';
</script>
{#if actionHandler}
<div
on:click={actionHandler}
on:keydown={actionHandler}
class="border dark:border-immich-dark-gray {hoverClasses} p-5 w-[50%] m-auto mt-10 bg-gray-50 dark:bg-immich-dark-gray rounded-3xl flex flex-col place-content-center place-items-center"
>
<img src={empty1Url} {alt} width="500" draggable="false" />
<p class="text-center text-immich-text-gray-500 dark:text-immich-dark-fg">{text}</p>
</div>
{:else}
<div
class="border dark:border-immich-dark-gray p-5 w-[50%] m-auto mt-10 bg-gray-50 dark:bg-immich-dark-gray rounded-3xl flex flex-col place-content-center place-items-center"
>
<img src={empty1Url} {alt} width="500" draggable="false" />
<p class="text-center text-immich-text-gray-500 dark:text-immich-dark-fg">{text}</p>
</div>
{/if}

View File

@ -4,6 +4,7 @@
import AccountMultipleOutline from 'svelte-material-icons/AccountMultipleOutline.svelte';
import ImageAlbum from 'svelte-material-icons/ImageAlbum.svelte';
import ImageOutline from 'svelte-material-icons/ImageOutline.svelte';
import ArchiveArrowDownOutline from 'svelte-material-icons/ArchiveArrowDownOutline.svelte';
import Magnify from 'svelte-material-icons/Magnify.svelte';
import StarOutline from 'svelte-material-icons/StarOutline.svelte';
import { AppRoute } from '../../../constants';
@ -13,16 +14,17 @@
import { locale } from '$lib/stores/preferences.store';
const getAssetCount = async () => {
const { data: assetCount } = await api.assetApi.getAssetCountByUserId();
const { data: allAssetCount } = await api.assetApi.getAssetCountByUserId();
const { data: archivedCount } = await api.assetApi.getArchivedAssetCountByUserId();
return {
videos: assetCount.videos,
photos: assetCount.photos
videos: allAssetCount.videos - archivedCount.videos,
photos: allAssetCount.photos - archivedCount.photos
};
};
const getFavoriteCount = async () => {
const { data: assets } = await api.assetApi.getAllAssets(true);
const { data: assets } = await api.assetApi.getAllAssets(true, undefined);
return {
favorites: assets.length
@ -37,6 +39,15 @@
owned: albumCount.owned
};
};
const getArchivedAssetsCount = async () => {
const { data: assetCount } = await api.assetApi.getArchivedAssetCountByUserId();
return {
videos: assetCount.videos,
photos: assetCount.photos
};
};
</script>
<section id="sidebar" class="flex flex-col gap-1 pt-8 pr-6 bg-immich-bg dark:bg-immich-dark-bg">
@ -130,6 +141,24 @@
</svelte:fragment>
</SideBarButton>
</a>
<a data-sveltekit-preload-data="hover" href={AppRoute.ARCHIVE} draggable="false">
<SideBarButton
title="Archive"
logo={ArchiveArrowDownOutline}
isSelected={$page.route.id === '/(user)/archive'}
>
<svelte:fragment slot="moreInformation">
{#await getArchivedAssetsCount()}
<LoadingSpinner />
{:then data}
<div>
<p>{data.videos.toLocaleString($locale)} Videos</p>
<p>{data.photos.toLocaleString($locale)} Photos</p>
</div>
{/await}
</svelte:fragment>
</SideBarButton>
</a>
<!-- Status Box -->
<div class="mb-6 mt-auto">

View File

@ -8,6 +8,7 @@ export enum AppRoute {
ADMIN_JOBS = '/admin/jobs-status',
ALBUMS = '/albums',
ARCHIVE = '/archive',
FAVORITES = '/favorites',
PHOTOS = '/photos',
EXPLORE = '/explore',

View File

@ -7,7 +7,7 @@
import type { PageData } from './$types';
import PlusBoxOutline from 'svelte-material-icons/PlusBoxOutline.svelte';
import { useAlbums } from './albums.bloc';
import empty1Url from '$lib/assets/empty-1.svg';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import LinkButton from '$lib/components/elements/buttons/link-button.svelte';
@ -57,17 +57,11 @@
<!-- Empty Message -->
{#if $albums.length === 0}
<div
on:click={handleCreateAlbum}
on:keydown={handleCreateAlbum}
class="border dark:border-immich-dark-gray hover:bg-immich-primary/5 dark:hover:bg-immich-dark-primary/25 hover:cursor-pointer p-5 w-[50%] m-auto mt-10 bg-gray-50 dark:bg-immich-dark-gray rounded-3xl flex flex-col place-content-center place-items-center"
>
<img src={empty1Url} alt="Empty shared album" width="500" draggable="false" />
<p class="text-center text-immich-text-gray-500 dark:text-immich-dark-fg">
Create an album to organize your photos and videos
</p>
</div>
<EmptyPlaceholder
text="Create an album to organize your photos and videos"
actionHandler={handleCreateAlbum}
alt="Empty albums"
/>
{/if}
</UserPageLayout>

View File

@ -0,0 +1,16 @@
import type { PageServerLoad } from './$types';
import { redirect } from '@sveltejs/kit';
import { AppRoute } from '$lib/constants';
export const load = (async ({ locals: { user } }) => {
if (!user) {
throw redirect(302, AppRoute.AUTH_LOGIN);
}
return {
user,
meta: {
title: 'Archive'
}
};
}) satisfies PageServerLoad;

View File

@ -0,0 +1,259 @@
<script lang="ts">
import { goto } from '$app/navigation';
import AlbumSelectionModal from '$lib/components/shared-components/album-selection-modal.svelte';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import CreateSharedLinkModal from '$lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte';
import {
notificationController,
NotificationType
} from '$lib/components/shared-components/notification/notification';
import { addAssetsToAlbum, bulkDownload } from '$lib/utils/asset-utils';
import { AlbumResponseDto, api, AssetResponseDto, SharedLinkType } from '@api';
import Close from 'svelte-material-icons/Close.svelte';
import CloudDownloadOutline from 'svelte-material-icons/CloudDownloadOutline.svelte';
import ArchiveArrowUpOutline from 'svelte-material-icons/ArchiveArrowUpOutline.svelte';
import DeleteOutline from 'svelte-material-icons/DeleteOutline.svelte';
import Plus from 'svelte-material-icons/Plus.svelte';
import ShareVariantOutline from 'svelte-material-icons/ShareVariantOutline.svelte';
import { locale } from '$lib/stores/preferences.store';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import type { PageData } from './$types';
import { onMount } from 'svelte';
import { handleError } from '$lib/utils/handle-error';
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
export let data: PageData;
onMount(async () => {
try {
const { data: assets } = await api.assetApi.getAllAssets(undefined, true);
archived = assets;
} catch {
handleError(Error, 'Unable to load archived assets');
}
});
const clearMultiSelectAssetAssetHandler = () => {
selectedAssets = new Set();
};
const deleteSelectedAssetHandler = async () => {
try {
if (
window.confirm(
`Caution! Are you sure you want to delete ${selectedAssets.size} assets? This step also deletes assets in the album(s) to which they belong. You can not undo this action!`
)
) {
const { data: deletedAssets } = await api.assetApi.deleteAsset({
ids: Array.from(selectedAssets).map((a) => a.id)
});
for (const asset of deletedAssets) {
if (asset.status == 'SUCCESS') {
archived = archived.filter((a) => a.id != asset.id);
}
}
clearMultiSelectAssetAssetHandler();
}
} catch (e) {
notificationController.show({
type: NotificationType.Error,
message: 'Error deleting assets, check console for more details'
});
console.error('Error deleteSelectedAssetHandler', e);
}
};
$: isMultiSelectionMode = selectedAssets.size > 0;
let selectedAssets: Set<AssetResponseDto> = new Set();
let archived: AssetResponseDto[] = [];
let contextMenuPosition = { x: 0, y: 0 };
let isShowCreateSharedLinkModal = false;
let isShowAddMenu = false;
let isShowAlbumPicker = false;
let addToSharedAlbum = false;
const handleShowMenu = ({ x, y }: MouseEvent) => {
contextMenuPosition = { x, y };
isShowAddMenu = !isShowAddMenu;
};
const handleAddToFavorites = () => {
isShowAddMenu = false;
let cnt = 0;
for (const asset of selectedAssets) {
if (!asset.isFavorite) {
api.assetApi.updateAsset(asset.id, {
isFavorite: true
});
cnt = cnt + 1;
}
}
notificationController.show({
message: `Added ${cnt} to favorites`,
type: NotificationType.Info
});
clearMultiSelectAssetAssetHandler();
};
const handleShowAlbumPicker = (shared: boolean) => {
isShowAddMenu = false;
isShowAlbumPicker = true;
addToSharedAlbum = shared;
};
const handleAddToNewAlbum = (event: CustomEvent) => {
isShowAlbumPicker = false;
const { albumName }: { albumName: string } = event.detail;
const assetIds = Array.from(selectedAssets).map((asset) => asset.id);
api.albumApi.createAlbum({ albumName, assetIds }).then((response) => {
const { id, albumName } = response.data;
notificationController.show({
message: `Added ${assetIds.length} to ${albumName}`,
type: NotificationType.Info
});
clearMultiSelectAssetAssetHandler();
goto('/albums/' + id);
});
};
const handleAddToAlbum = async (event: CustomEvent<{ album: AlbumResponseDto }>) => {
isShowAlbumPicker = false;
const album = event.detail.album;
const assetIds = Array.from(selectedAssets).map((asset) => asset.id);
addAssetsToAlbum(album.id, assetIds).then(() => {
clearMultiSelectAssetAssetHandler();
});
};
const handleDownloadFiles = async () => {
await bulkDownload('immich', Array.from(selectedAssets), () => {
clearMultiSelectAssetAssetHandler();
});
};
const handleUnarchive = async () => {
let cnt = 0;
for (const asset of selectedAssets) {
if (asset.isArchived) {
api.assetApi.updateAsset(asset.id, {
isArchived: false
});
cnt = cnt + 1;
archived = archived.filter((a) => a.id != asset.id);
}
}
notificationController.show({
message: `Removed ${cnt} from archive`,
type: NotificationType.Info
});
clearMultiSelectAssetAssetHandler();
};
const handleCreateSharedLink = async () => {
isShowCreateSharedLinkModal = true;
};
const handleCloseSharedLinkModal = () => {
clearMultiSelectAssetAssetHandler();
isShowCreateSharedLinkModal = false;
};
</script>
<UserPageLayout user={data.user} hideNavbar={isMultiSelectionMode}>
<!-- Empty Message -->
{#if archived.length === 0}
<EmptyPlaceholder
text="Archive photos and videos to hide them from your Photos view"
alt="Empty archive"
/>
{/if}
<svelte:fragment slot="header">
{#if isMultiSelectionMode}
<ControlAppBar
on:close-button-click={clearMultiSelectAssetAssetHandler}
backIcon={Close}
tailwindClasses={'bg-white shadow-md'}
>
<svelte:fragment slot="leading">
<p class="font-medium text-immich-primary dark:text-immich-dark-primary">
Selected {selectedAssets.size.toLocaleString($locale)}
</p>
</svelte:fragment>
<svelte:fragment slot="trailing">
<CircleIconButton
title="Share"
logo={ShareVariantOutline}
on:click={handleCreateSharedLink}
/>
<CircleIconButton
title="Unarchive"
logo={ArchiveArrowUpOutline}
on:click={handleUnarchive}
/>
<CircleIconButton
title="Download"
logo={CloudDownloadOutline}
on:click={handleDownloadFiles}
/>
<CircleIconButton title="Add" logo={Plus} on:click={handleShowMenu} />
<CircleIconButton
title="Delete"
logo={DeleteOutline}
on:click={deleteSelectedAssetHandler}
/>
</svelte:fragment>
</ControlAppBar>
{/if}
{#if isShowAddMenu}
<ContextMenu {...contextMenuPosition} on:clickoutside={() => (isShowAddMenu = false)}>
<div class="flex flex-col rounded-lg ">
<MenuOption on:click={handleAddToFavorites} text="Add to Favorites" />
<MenuOption on:click={() => handleShowAlbumPicker(false)} text="Add to Album" />
<MenuOption on:click={() => handleShowAlbumPicker(true)} text="Add to Shared Album" />
</div>
</ContextMenu>
{/if}
{#if isShowAlbumPicker}
<AlbumSelectionModal
shared={addToSharedAlbum}
on:newAlbum={handleAddToNewAlbum}
on:newSharedAlbum={handleAddToNewAlbum}
on:album={handleAddToAlbum}
on:close={() => (isShowAlbumPicker = false)}
/>
{/if}
{#if isShowCreateSharedLinkModal}
<CreateSharedLinkModal
sharedAssets={Array.from(selectedAssets)}
shareType={SharedLinkType.Individual}
on:close={handleCloseSharedLinkModal}
/>
{/if}
</svelte:fragment>
<GalleryViewer assets={archived} bind:selectedAssets />
</UserPageLayout>

View File

@ -10,7 +10,7 @@
import ShareVariantOutline from 'svelte-material-icons/ShareVariantOutline.svelte';
import StarMinusOutline from 'svelte-material-icons/StarMinusOutline.svelte';
import Error from '../../+error.svelte';
import empty1Url from '$lib/assets/empty-1.svg';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import type { PageData } from './$types';
@ -24,7 +24,7 @@
onMount(async () => {
try {
const { data: assets } = await api.assetApi.getAllAssets(true);
const { data: assets } = await api.assetApi.getAllAssets(true, undefined);
favorites = assets;
} catch {
handleError(Error, 'Unable to load favorites');
@ -96,19 +96,14 @@
/>
{/if}
<UserPageLayout user={data.user} title={data.meta.title} hideNavbar={isMultiSelectionMode}>
<UserPageLayout user={data.user} hideNavbar={isMultiSelectionMode}>
<section>
<!-- Empty Message -->
{#if favorites.length === 0}
<div
class="border dark:border-immich-dark-gray hover:bg-immich-primary/5 dark:hover:bg-immich-dark-primary/25 hover:cursor-pointer p-5 w-[50%] m-auto mt-10 bg-gray-50 dark:bg-immich-dark-gray rounded-3xl flex flex-col place-content-center place-items-center"
>
<img src={empty1Url} alt="Empty shared album" width="500" draggable="false" />
<p class="text-center text-immich-text-gray-500 dark:text-immich-dark-fg">
Add favorites to quickly find your best pictures and videos
</p>
</div>
<EmptyPlaceholder
text="Add favorites to quickly find your best pictures and videos"
alt="Empty favorites"
/>
{/if}
<GalleryViewer assets={favorites} bind:selectedAssets />

View File

@ -19,6 +19,7 @@
import { assetStore } from '$lib/stores/assets.store';
import { addAssetsToAlbum, bulkDownload } from '$lib/utils/asset-utils';
import { AlbumResponseDto, api, SharedLinkType } from '@api';
import ArchiveArrowDownOutline from 'svelte-material-icons/ArchiveArrowDownOutline.svelte';
import Close from 'svelte-material-icons/Close.svelte';
import CloudDownloadOutline from 'svelte-material-icons/CloudDownloadOutline.svelte';
import DeleteOutline from 'svelte-material-icons/DeleteOutline.svelte';
@ -69,6 +70,27 @@
isShowAddMenu = !isShowAddMenu;
};
const handleArchive = async () => {
let cnt = 0;
for (const asset of $selectedAssets) {
if (!asset.isArchived) {
api.assetApi.updateAsset(asset.id, {
isArchived: true
});
assetStore.removeAsset(asset.id);
cnt = cnt + 1;
}
}
notificationController.show({
message: `Archived ${cnt}`,
type: NotificationType.Info
});
assetInteractionStore.clearMultiselect();
};
const handleAddToFavorites = () => {
isShowAddMenu = false;
@ -162,6 +184,11 @@
logo={ShareVariantOutline}
on:click={handleCreateSharedLink}
/>
<CircleIconButton
title="Archive"
logo={ArchiveArrowDownOutline}
on:click={handleArchive}
/>
<CircleIconButton
title="Download"
logo={CloudDownloadOutline}

View File

@ -24,6 +24,7 @@ export const load = (async ({ locals, parent, url }) => {
undefined,
undefined,
undefined,
undefined,
{ params: url.searchParams }
);