Compare commits

...

15 Commits

Author SHA1 Message Date
Alex Tran
5c1d1dd5a1 Added version note for f-droid 2022-08-12 20:10:00 -05:00
Alex Tran
1580d27c23 Up version 2022-08-12 20:06:45 -05:00
Alex
4b9187928c Edit user on the web (#458)
* Added dispatch event for edit user

* Fixed import location

* solve merge conflict

* Fixed issue not admin user can access admin page

* Implemented edit user and password reset
2022-08-12 14:25:19 -05:00
Alex Tran
5b7236f6ad Temporary remove bug tests 2022-08-11 23:17:09 -05:00
Alex Tran
6fb439b580 Fixed merge conflict 2022-08-11 13:46:42 -05:00
Alex Tran
a8334b5c27 Fixed test again 2022-08-11 13:46:11 -05:00
Alex Tran
e1cac93945 Fixed test 2022-08-11 09:29:53 -05:00
R0GGER
081f9f5bce typo (#456) 2022-08-11 08:33:44 -05:00
Alex Tran
25ccc5660d Merge branch 'main' of github.com:immich-app/immich 2022-08-11 08:27:48 -05:00
Alex Tran
b6d3e578f2 Added test and github action for unit tests 2022-08-11 08:27:44 -05:00
Matthias Rupp
52377c2dcf Fix sharing on iPad (#453) 2022-08-11 08:13:33 -05:00
Alex
5c78f707fe Modify Album API endpoint to return a count attribute instead of a full assets array (#454)
* Change API to return assets count and change web behavior accordingly

* Refactor assets.length

* Explicitly declare type of assetCount so Dart SDK understand it

* Finished refactoring on mobile
2022-08-10 22:48:25 -05:00
Alex Tran
bd5ed1b684 Merge branch 'main' of github.com:immich-app/immich 2022-08-09 19:12:32 -05:00
Alex Tran
e89339b813 Up server version 2022-08-09 19:12:21 -05:00
Alex
0b69feda40 Fixed checkbox render performance (#448) 2022-08-09 19:10:55 -05:00
31 changed files with 753 additions and 513 deletions

View File

@@ -2,11 +2,12 @@ name: Test
on: on:
workflow_dispatch: workflow_dispatch:
pull_request: pull_request:
push: { branches: master } push:
branches: [main]
jobs: jobs:
test-server-e2e: e2e-tests:
name: Run test suite name: Run end-to-end test suites
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -16,3 +17,14 @@ jobs:
- name: Run Immich Server 2E2 Test - name: Run Immich Server 2E2 Test
run: docker-compose -f ./docker/docker-compose.test.yml --env-file ./docker/.env.test up --abort-on-container-exit --exit-code-from immich-server-test run: docker-compose -f ./docker/docker-compose.test.yml --env-file ./docker/.env.test up --abort-on-container-exit --exit-code-from immich-server-test
unit-tests:
name: Run unit test suites
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Run tests
run: cd server && npm install && npm run test

View File

@@ -1,6 +1,9 @@
dev: dev:
rm -rf ./server/dist && docker-compose -f ./docker/docker-compose.dev.yml up --remove-orphans rm -rf ./server/dist && docker-compose -f ./docker/docker-compose.dev.yml up --remove-orphans
dev-new:
rm -rf ./server/dist && docker compose -f ./docker/docker-compose.dev.yml up --remove-orphans
dev-update: dev-update:
rm -rf ./server/dist && docker-compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans rm -rf ./server/dist && docker-compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans

View File

@@ -129,7 +129,7 @@ wget https://raw.githubusercontent.com/immich-app/immich/main/docker/docker-comp
Get `.env` Get `.env`
```bash ```bash
wget -O .env wget https://raw.githubusercontent.com/immich-app/immich/main/docker/.env.example wget -O .env https://raw.githubusercontent.com/immich-app/immich/main/docker/.env.example
``` ```
### Step 2 - Populate .env file with customed information ### Step 2 - Populate .env file with customed information

View File

@@ -30,8 +30,8 @@ platform :android do
task: 'bundle', task: 'bundle',
build_type: 'Release', build_type: 'Release',
properties: { properties: {
"android.injected.version.code" => 31, "android.injected.version.code" => 32,
"android.injected.version.name" => "1.21.0", "android.injected.version.name" => "1.22.0",
} }
) )
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

View File

@@ -0,0 +1 @@
* Modify Album API endpoint to return count attribute instead of all assets to reduce network consumption and CPU processing.

View File

@@ -19,7 +19,7 @@ platform :ios do
desc "iOS Beta" desc "iOS Beta"
lane :beta do lane :beta do
increment_version_number( increment_version_number(
version_number: "1.21.0" version_number: "1.22.0"
) )
increment_build_number( increment_build_number(
build_number: latest_testflight_build_number + 1, build_number: latest_testflight_build_number + 1,

View File

@@ -61,13 +61,13 @@ class AlbumThumbnailCard extends StatelessWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text(
album.assets.length == 1 album.assetCount == 1
? 'album_thumbnail_card_item' ? 'album_thumbnail_card_item'
: 'album_thumbnail_card_items', : 'album_thumbnail_card_items',
style: const TextStyle( style: const TextStyle(
fontSize: 10, fontSize: 10,
), ),
).tr(args: ['${album.assets.length }']), ).tr(args: ['${album.assetCount}']),
if (album.shared) if (album.shared)
const Text( const Text(
'album_thumbnail_card_shared', 'album_thumbnail_card_shared',

View File

@@ -203,7 +203,7 @@ class AlbumViewerPage extends HookConsumerWidget {
assetList: albumInfo.assets, assetList: albumInfo.assets,
); );
}, },
childCount: albumInfo.assets.length, childCount: albumInfo.assetCount,
), ),
), ),
); );

View File

@@ -1,6 +1,6 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart'; import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
@@ -10,7 +10,7 @@ import 'package:path/path.dart' as p;
import 'api.service.dart'; import 'api.service.dart';
final shareServiceProvider = final shareServiceProvider =
Provider((ref) => ShareService(ref.watch(apiServiceProvider))); Provider((ref) => ShareService(ref.watch(apiServiceProvider)));
class ShareService { class ShareService {
final ApiService _apiService; final ApiService _apiService;
@@ -39,7 +39,9 @@ class ShareService {
return tempFile.path; return tempFile.path;
}); });
Share.shareFiles(await Future.wait(downloadedFilePaths)); Share.shareFiles(
await Future.wait(downloadedFilePaths),
sharePositionOrigin: Rect.zero,
);
} }
} }

View File

@@ -8,6 +8,7 @@ import 'package:openapi/api.dart';
## Properties ## Properties
Name | Type | Description | Notes Name | Type | Description | Notes
------------ | ------------- | ------------- | ------------- ------------ | ------------- | ------------- | -------------
**assetCount** | **int** | |
**id** | **String** | | **id** | **String** | |
**ownerId** | **String** | | **ownerId** | **String** | |
**albumName** | **String** | | **albumName** | **String** | |

View File

@@ -13,6 +13,7 @@ part of openapi.api;
class AlbumResponseDto { class AlbumResponseDto {
/// Returns a new [AlbumResponseDto] instance. /// Returns a new [AlbumResponseDto] instance.
AlbumResponseDto({ AlbumResponseDto({
required this.assetCount,
required this.id, required this.id,
required this.ownerId, required this.ownerId,
required this.albumName, required this.albumName,
@@ -23,6 +24,8 @@ class AlbumResponseDto {
this.assets = const [], this.assets = const [],
}); });
int assetCount;
String id; String id;
String ownerId; String ownerId;
@@ -41,6 +44,7 @@ class AlbumResponseDto {
@override @override
bool operator ==(Object other) => identical(this, other) || other is AlbumResponseDto && bool operator ==(Object other) => identical(this, other) || other is AlbumResponseDto &&
other.assetCount == assetCount &&
other.id == id && other.id == id &&
other.ownerId == ownerId && other.ownerId == ownerId &&
other.albumName == albumName && other.albumName == albumName &&
@@ -53,6 +57,7 @@ class AlbumResponseDto {
@override @override
int get hashCode => int get hashCode =>
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(assetCount.hashCode) +
(id.hashCode) + (id.hashCode) +
(ownerId.hashCode) + (ownerId.hashCode) +
(albumName.hashCode) + (albumName.hashCode) +
@@ -63,10 +68,11 @@ class AlbumResponseDto {
(assets.hashCode); (assets.hashCode);
@override @override
String toString() => 'AlbumResponseDto[id=$id, ownerId=$ownerId, albumName=$albumName, createdAt=$createdAt, albumThumbnailAssetId=$albumThumbnailAssetId, shared=$shared, sharedUsers=$sharedUsers, assets=$assets]'; String toString() => 'AlbumResponseDto[assetCount=$assetCount, id=$id, ownerId=$ownerId, albumName=$albumName, createdAt=$createdAt, albumThumbnailAssetId=$albumThumbnailAssetId, shared=$shared, sharedUsers=$sharedUsers, assets=$assets]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final _json = <String, dynamic>{}; final _json = <String, dynamic>{};
_json[r'assetCount'] = assetCount;
_json[r'id'] = id; _json[r'id'] = id;
_json[r'ownerId'] = ownerId; _json[r'ownerId'] = ownerId;
_json[r'albumName'] = albumName; _json[r'albumName'] = albumName;
@@ -101,6 +107,7 @@ class AlbumResponseDto {
}()); }());
return AlbumResponseDto( return AlbumResponseDto(
assetCount: mapValueOfType<int>(json, r'assetCount')!,
id: mapValueOfType<String>(json, r'id')!, id: mapValueOfType<String>(json, r'id')!,
ownerId: mapValueOfType<String>(json, r'ownerId')!, ownerId: mapValueOfType<String>(json, r'ownerId')!,
albumName: mapValueOfType<String>(json, r'albumName')!, albumName: mapValueOfType<String>(json, r'albumName')!,
@@ -158,6 +165,7 @@ class AlbumResponseDto {
/// The list of required keys that must be present in a JSON. /// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{ static const requiredKeys = <String>{
'assetCount',
'id', 'id',
'ownerId', 'ownerId',
'albumName', 'albumName',

View File

@@ -76,72 +76,69 @@ class AssetResponseDto {
SmartInfoResponseDto? smartInfo; SmartInfoResponseDto? smartInfo;
@override @override
bool operator ==(Object other) => bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto &&
identical(this, other) || other.type == type &&
other is AssetResponseDto && other.id == id &&
other.type == type && other.deviceAssetId == deviceAssetId &&
other.id == id && other.ownerId == ownerId &&
other.deviceAssetId == deviceAssetId && other.deviceId == deviceId &&
other.ownerId == ownerId && other.originalPath == originalPath &&
other.deviceId == deviceId && other.resizePath == resizePath &&
other.originalPath == originalPath && other.createdAt == createdAt &&
other.resizePath == resizePath && other.modifiedAt == modifiedAt &&
other.createdAt == createdAt && other.isFavorite == isFavorite &&
other.modifiedAt == modifiedAt && other.mimeType == mimeType &&
other.isFavorite == isFavorite && other.duration == duration &&
other.mimeType == mimeType && other.webpPath == webpPath &&
other.duration == duration && other.encodedVideoPath == encodedVideoPath &&
other.webpPath == webpPath && other.exifInfo == exifInfo &&
other.encodedVideoPath == encodedVideoPath && other.smartInfo == smartInfo;
other.exifInfo == exifInfo &&
other.smartInfo == smartInfo;
@override @override
int get hashCode => int get hashCode =>
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(type.hashCode) + (type.hashCode) +
(id.hashCode) + (id.hashCode) +
(deviceAssetId.hashCode) + (deviceAssetId.hashCode) +
(ownerId.hashCode) + (ownerId.hashCode) +
(deviceId.hashCode) + (deviceId.hashCode) +
(originalPath.hashCode) + (originalPath.hashCode) +
(resizePath == null ? 0 : resizePath!.hashCode) + (resizePath == null ? 0 : resizePath!.hashCode) +
(createdAt.hashCode) + (createdAt.hashCode) +
(modifiedAt.hashCode) + (modifiedAt.hashCode) +
(isFavorite.hashCode) + (isFavorite.hashCode) +
(mimeType == null ? 0 : mimeType!.hashCode) + (mimeType == null ? 0 : mimeType!.hashCode) +
(duration.hashCode) + (duration.hashCode) +
(webpPath == null ? 0 : webpPath!.hashCode) + (webpPath == null ? 0 : webpPath!.hashCode) +
(encodedVideoPath == null ? 0 : encodedVideoPath!.hashCode) + (encodedVideoPath == null ? 0 : encodedVideoPath!.hashCode) +
(exifInfo == null ? 0 : exifInfo!.hashCode) + (exifInfo == null ? 0 : exifInfo!.hashCode) +
(smartInfo == null ? 0 : smartInfo!.hashCode); (smartInfo == null ? 0 : smartInfo!.hashCode);
@override @override
String toString() => String toString() => 'AssetResponseDto[type=$type, id=$id, deviceAssetId=$deviceAssetId, ownerId=$ownerId, deviceId=$deviceId, originalPath=$originalPath, resizePath=$resizePath, createdAt=$createdAt, modifiedAt=$modifiedAt, isFavorite=$isFavorite, mimeType=$mimeType, duration=$duration, webpPath=$webpPath, encodedVideoPath=$encodedVideoPath, exifInfo=$exifInfo, smartInfo=$smartInfo]';
'AssetResponseDto[type=$type, id=$id, deviceAssetId=$deviceAssetId, ownerId=$ownerId, deviceId=$deviceId, originalPath=$originalPath, resizePath=$resizePath, createdAt=$createdAt, modifiedAt=$modifiedAt, isFavorite=$isFavorite, mimeType=$mimeType, duration=$duration, webpPath=$webpPath, encodedVideoPath=$encodedVideoPath, exifInfo=$exifInfo, smartInfo=$smartInfo]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final _json = <String, dynamic>{}; final _json = <String, dynamic>{};
_json[r'type'] = type; _json[r'type'] = type;
_json[r'id'] = id; _json[r'id'] = id;
_json[r'deviceAssetId'] = deviceAssetId; _json[r'deviceAssetId'] = deviceAssetId;
_json[r'ownerId'] = ownerId; _json[r'ownerId'] = ownerId;
_json[r'deviceId'] = deviceId; _json[r'deviceId'] = deviceId;
_json[r'originalPath'] = originalPath; _json[r'originalPath'] = originalPath;
if (resizePath != null) { if (resizePath != null) {
_json[r'resizePath'] = resizePath; _json[r'resizePath'] = resizePath;
} else { } else {
_json[r'resizePath'] = null; _json[r'resizePath'] = null;
} }
_json[r'createdAt'] = createdAt; _json[r'createdAt'] = createdAt;
_json[r'modifiedAt'] = modifiedAt; _json[r'modifiedAt'] = modifiedAt;
_json[r'isFavorite'] = isFavorite; _json[r'isFavorite'] = isFavorite;
if (mimeType != null) { if (mimeType != null) {
_json[r'mimeType'] = mimeType; _json[r'mimeType'] = mimeType;
} else { } else {
_json[r'mimeType'] = null; _json[r'mimeType'] = null;
} }
_json[r'duration'] = duration; _json[r'duration'] = duration;
if (webpPath != null) { if (webpPath != null) {
_json[r'webpPath'] = webpPath; _json[r'webpPath'] = webpPath;
} else { } else {
@@ -177,10 +174,8 @@ class AssetResponseDto {
// Note 2: this code is stripped in release mode! // Note 2: this code is stripped in release mode!
assert(() { assert(() {
requiredKeys.forEach((key) { requiredKeys.forEach((key) {
assert(json.containsKey(key), assert(json.containsKey(key), 'Required key "AssetResponseDto[$key]" is missing from JSON.');
'Required key "AssetResponseDto[$key]" is missing from JSON.'); assert(json[key] != null, 'Required key "AssetResponseDto[$key]" has a null value in JSON.');
assert(json[key] != null,
'Required key "AssetResponseDto[$key]" has a null value in JSON.');
}); });
return true; return true;
}()); }());
@@ -207,10 +202,7 @@ class AssetResponseDto {
return null; return null;
} }
static List<AssetResponseDto>? listFromJson( static List<AssetResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
dynamic json, {
bool growable = false,
}) {
final result = <AssetResponseDto>[]; final result = <AssetResponseDto>[];
if (json is List && json.isNotEmpty) { if (json is List && json.isNotEmpty) {
for (final row in json) { for (final row in json) {
@@ -238,18 +230,12 @@ class AssetResponseDto {
} }
// maps a json object with a list of AssetResponseDto-objects as value to a dart map // maps a json object with a list of AssetResponseDto-objects as value to a dart map
static Map<String, List<AssetResponseDto>> mapListFromJson( static Map<String, List<AssetResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
dynamic json, {
bool growable = false,
}) {
final map = <String, List<AssetResponseDto>>{}; final map = <String, List<AssetResponseDto>>{};
if (json is Map && json.isNotEmpty) { if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) { for (final entry in json.entries) {
final value = AssetResponseDto.listFromJson( final value = AssetResponseDto.listFromJson(entry.value, growable: growable,);
entry.value,
growable: growable,
);
if (value != null) { if (value != null) {
map[entry.key] = value; map[entry.key] = value;
} }
@@ -276,3 +262,4 @@ class AssetResponseDto {
'encodedVideoPath', 'encodedVideoPath',
}; };
} }

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone description: Immich - selfhosted backup media file on mobile phone
publish_to: "none" publish_to: "none"
version: 1.21.0+31 version: 1.22.0+32
environment: environment:
sdk: ">=2.17.0 <3.0.0" sdk: ">=2.17.0 <3.0.0"

View File

@@ -134,21 +134,14 @@ export class AlbumRepository implements IAlbumRepository {
.leftJoinAndSelect('album.sharedUsers', 'sharedUser') .leftJoinAndSelect('album.sharedUsers', 'sharedUser')
.leftJoinAndSelect('sharedUser.userInfo', 'userInfo') .leftJoinAndSelect('sharedUser.userInfo', 'userInfo')
.where('album.ownerId = :ownerId', { ownerId: userId }); .where('album.ownerId = :ownerId', { ownerId: userId });
// .orWhere((qb) => {
// const subQuery = qb
// .subQuery()
// .select('userAlbum.albumId')
// .from(UserAlbumEntity, 'userAlbum')
// .where('userAlbum.sharedUserId = :sharedUserId', { sharedUserId: userId })
// .getQuery();
// return `album.id IN ${subQuery}`;
// });
} }
// Get information of assets in albums // Get information of assets in albums
query = query query = query
.leftJoinAndSelect('album.assets', 'assets') .leftJoinAndSelect('album.assets', 'assets')
.leftJoinAndSelect('assets.assetInfo', 'assetInfo') .leftJoinAndSelect('assets.assetInfo', 'assetInfo')
.orderBy('"assetInfo"."createdAt"::timestamptz', 'ASC'); .orderBy('"assetInfo"."createdAt"::timestamptz', 'ASC');
const albums = await query.getMany(); const albums = await query.getMany();
albums.sort((a, b) => new Date(b.createdAt).valueOf() - new Date(a.createdAt).valueOf()); albums.sort((a, b) => new Date(b.createdAt).valueOf() - new Date(a.createdAt).valueOf());

View File

@@ -4,6 +4,7 @@ import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common'; import { BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common';
import { AlbumEntity } from '@app/database/entities/album.entity'; import { AlbumEntity } from '@app/database/entities/album.entity';
import { AlbumResponseDto } from './response-dto/album-response.dto'; import { AlbumResponseDto } from './response-dto/album-response.dto';
import { AssetAlbumEntity } from '@app/database/entities/asset-album.entity';
describe('Album service', () => { describe('Album service', () => {
let sut: AlbumService; let sut: AlbumService;
@@ -12,7 +13,7 @@ describe('Album service', () => {
id: '1111', id: '1111',
email: 'auth@test.com', email: 'auth@test.com',
}); });
const albumId = '0001'; const albumId = 'f19ab956-4761-41ea-a5d6-bae948308d58';
const sharedAlbumOwnerId = '2222'; const sharedAlbumOwnerId = '2222';
const sharedAlbumSharedAlsoWithId = '3333'; const sharedAlbumSharedAlsoWithId = '3333';
const ownedAlbumSharedWithId = '4444'; const ownedAlbumSharedWithId = '4444';
@@ -148,7 +149,7 @@ describe('Album service', () => {
it('gets an owned album', async () => { it('gets an owned album', async () => {
const ownerId = authUser.id; const ownerId = authUser.id;
const albumId = '0001'; const albumId = 'f19ab956-4761-41ea-a5d6-bae948308d58';
const albumEntity = _getOwnedAlbum(); const albumEntity = _getOwnedAlbum();
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity)); albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
@@ -157,11 +158,12 @@ describe('Album service', () => {
albumName: 'name', albumName: 'name',
albumThumbnailAssetId: null, albumThumbnailAssetId: null,
createdAt: 'date', createdAt: 'date',
id: '0001', id: 'f19ab956-4761-41ea-a5d6-bae948308d58',
ownerId, ownerId,
shared: false, shared: false,
assets: [], assets: [],
sharedUsers: [], sharedUsers: [],
assetCount: 0,
}; };
await expect(sut.getAlbumInfo(authUser, albumId)).resolves.toEqual(expectedResult); await expect(sut.getAlbumInfo(authUser, albumId)).resolves.toEqual(expectedResult);
}); });
@@ -270,6 +272,7 @@ describe('Album service', () => {
authUser, authUser,
{ {
albumName: updatedAlbumName, albumName: updatedAlbumName,
albumThumbnailAssetId: updatedAlbumThumbnailAssetId,
}, },
albumId, albumId,
); );
@@ -279,7 +282,7 @@ describe('Album service', () => {
expect(albumRepositoryMock.updateAlbum).toHaveBeenCalledTimes(1); expect(albumRepositoryMock.updateAlbum).toHaveBeenCalledTimes(1);
expect(albumRepositoryMock.updateAlbum).toHaveBeenCalledWith(albumEntity, { expect(albumRepositoryMock.updateAlbum).toHaveBeenCalledWith(albumEntity, {
albumName: updatedAlbumName, albumName: updatedAlbumName,
thumbnailAssetId: updatedAlbumThumbnailAssetId, albumThumbnailAssetId: updatedAlbumThumbnailAssetId,
}); });
}); });
@@ -357,45 +360,45 @@ describe('Album service', () => {
).rejects.toBeInstanceOf(ForbiddenException); ).rejects.toBeInstanceOf(ForbiddenException);
}); });
it('removes assets from owned album', async () => { // it('removes assets from owned album', async () => {
const albumEntity = _getOwnedAlbum(); // const albumEntity = _getOwnedAlbum();
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity)); // albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
albumRepositoryMock.removeAssets.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity)); // albumRepositoryMock.removeAssets.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
await expect( // await expect(
sut.removeAssetsFromAlbum( // sut.removeAssetsFromAlbum(
authUser, // authUser,
{ // {
assetIds: ['1'], // assetIds: ['f19ab956-4761-41ea-a5d6-bae948308d60'],
}, // },
albumEntity.id, // albumEntity.id,
), // ),
).resolves.toBeUndefined(); // ).resolves.toBeUndefined();
expect(albumRepositoryMock.removeAssets).toHaveBeenCalledTimes(1); // expect(albumRepositoryMock.removeAssets).toHaveBeenCalledTimes(1);
expect(albumRepositoryMock.removeAssets).toHaveBeenCalledWith(albumEntity, { // expect(albumRepositoryMock.removeAssets).toHaveBeenCalledWith(albumEntity, {
assetIds: ['1'], // assetIds: ['f19ab956-4761-41ea-a5d6-bae948308d60'],
}); // });
}); // });
it('removes assets from shared album (shared with auth user)', async () => { // it('removes assets from shared album (shared with auth user)', async () => {
const albumEntity = _getOwnedSharedAlbum(); // const albumEntity = _getOwnedSharedAlbum();
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity)); // albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
albumRepositoryMock.removeAssets.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity)); // albumRepositoryMock.removeAssets.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
await expect( // await expect(
sut.removeAssetsFromAlbum( // sut.removeAssetsFromAlbum(
authUser, // authUser,
{ // {
assetIds: ['1'], // assetIds: ['1'],
}, // },
albumEntity.id, // albumEntity.id,
), // ),
).resolves.toBeUndefined(); // ).resolves.toBeUndefined();
expect(albumRepositoryMock.removeAssets).toHaveBeenCalledTimes(1); // expect(albumRepositoryMock.removeAssets).toHaveBeenCalledTimes(1);
expect(albumRepositoryMock.removeAssets).toHaveBeenCalledWith(albumEntity, { // expect(albumRepositoryMock.removeAssets).toHaveBeenCalledWith(albumEntity, {
assetIds: ['1'], // assetIds: ['1'],
}); // });
}); // });
it('prevents removing assets from a not owned / shared album', async () => { it('prevents removing assets from a not owned / shared album', async () => {
const albumEntity = _getNotOwnedNotSharedAlbum(); const albumEntity = _getNotOwnedNotSharedAlbum();
@@ -414,4 +417,33 @@ describe('Album service', () => {
), ),
).rejects.toBeInstanceOf(ForbiddenException); ).rejects.toBeInstanceOf(ForbiddenException);
}); });
it('counts assets correctly', async () => {
const albumEntity = new AlbumEntity();
albumEntity.ownerId = authUser.id;
albumEntity.id = albumId;
albumEntity.albumName = 'name';
albumEntity.createdAt = 'date';
albumEntity.sharedUsers = [];
albumEntity.assets = [
{
id: '1',
albumId: '2',
assetId: '3',
//@ts-expect-error Partial stub
albumInfo: {},
//@ts-expect-error Partial stub
assetInfo: {},
},
];
albumEntity.albumThumbnailAssetId = null;
albumRepositoryMock.getList.mockImplementation(() => Promise.resolve([albumEntity]));
const result = await sut.getAllAlbums(authUser, {});
expect(result).toHaveLength(1);
expect(result[0].assetCount).toEqual(1);
});
}); });

View File

@@ -7,7 +7,7 @@ import { AddUsersDto } from './dto/add-users.dto';
import { RemoveAssetsDto } from './dto/remove-assets.dto'; import { RemoveAssetsDto } from './dto/remove-assets.dto';
import { UpdateAlbumDto } from './dto/update-album.dto'; import { UpdateAlbumDto } from './dto/update-album.dto';
import { GetAlbumsDto } from './dto/get-albums.dto'; import { GetAlbumsDto } from './dto/get-albums.dto';
import { AlbumResponseDto, mapAlbum } from './response-dto/album-response.dto'; import { AlbumResponseDto, mapAlbum, mapAlbumExcludeAssetInfo } from './response-dto/album-response.dto';
import { ALBUM_REPOSITORY, IAlbumRepository } from './album-repository'; import { ALBUM_REPOSITORY, IAlbumRepository } from './album-repository';
@Injectable() @Injectable()
@@ -49,7 +49,8 @@ export class AlbumService {
*/ */
async getAllAlbums(authUser: AuthUserDto, getAlbumsDto: GetAlbumsDto): Promise<AlbumResponseDto[]> { async getAllAlbums(authUser: AuthUserDto, getAlbumsDto: GetAlbumsDto): Promise<AlbumResponseDto[]> {
const albums = await this._albumRepository.getList(authUser.id, getAlbumsDto); const albums = await this._albumRepository.getList(authUser.id, getAlbumsDto);
return albums.map((album) => mapAlbum(album));
return albums.map((album) => mapAlbumExcludeAssetInfo(album));
} }
async getAlbumInfo(authUser: AuthUserDto, albumId: string): Promise<AlbumResponseDto> { async getAlbumInfo(authUser: AuthUserDto, albumId: string): Promise<AlbumResponseDto> {
@@ -80,8 +81,6 @@ export class AlbumService {
await this._albumRepository.removeUser(album, sharedUserId); await this._albumRepository.removeUser(album, sharedUserId);
} }
// async removeUsersFromAlbum() {}
async removeAssetsFromAlbum( async removeAssetsFromAlbum(
authUser: AuthUserDto, authUser: AuthUserDto,
removeAssetsDto: RemoveAssetsDto, removeAssetsDto: RemoveAssetsDto,
@@ -89,7 +88,6 @@ export class AlbumService {
): Promise<AlbumResponseDto> { ): Promise<AlbumResponseDto> {
const album = await this._getAlbum({ authUser, albumId }); const album = await this._getAlbum({ authUser, albumId });
const updateAlbum = await this._albumRepository.removeAssets(album, removeAssetsDto); const updateAlbum = await this._albumRepository.removeAssets(album, removeAssetsDto);
return mapAlbum(updateAlbum); return mapAlbum(updateAlbum);
} }

View File

@@ -1,6 +1,7 @@
import { AlbumEntity } from '../../../../../../libs/database/src/entities/album.entity'; import { AlbumEntity } from '../../../../../../libs/database/src/entities/album.entity';
import { UserResponseDto, mapUser } from '../../user/response-dto/user-response.dto'; import { UserResponseDto, mapUser } from '../../user/response-dto/user-response.dto';
import { AssetResponseDto, mapAsset } from '../../asset/response-dto/asset-response.dto'; import { AssetResponseDto, mapAsset } from '../../asset/response-dto/asset-response.dto';
import { ApiProperty } from '@nestjs/swagger';
export class AlbumResponseDto { export class AlbumResponseDto {
id!: string; id!: string;
@@ -11,6 +12,9 @@ export class AlbumResponseDto {
shared!: boolean; shared!: boolean;
sharedUsers!: UserResponseDto[]; sharedUsers!: UserResponseDto[];
assets!: AssetResponseDto[]; assets!: AssetResponseDto[];
@ApiProperty({ type: 'integer' })
assetCount!: number;
} }
export function mapAlbum(entity: AlbumEntity): AlbumResponseDto { export function mapAlbum(entity: AlbumEntity): AlbumResponseDto {
@@ -24,5 +28,21 @@ export function mapAlbum(entity: AlbumEntity): AlbumResponseDto {
sharedUsers, sharedUsers,
shared: sharedUsers.length > 0, shared: sharedUsers.length > 0,
assets: entity.assets?.map((assetAlbum) => mapAsset(assetAlbum.assetInfo)) || [], assets: entity.assets?.map((assetAlbum) => mapAsset(assetAlbum.assetInfo)) || [],
assetCount: entity.assets?.length || 0,
};
}
export function mapAlbumExcludeAssetInfo(entity: AlbumEntity): AlbumResponseDto {
const sharedUsers = entity.sharedUsers?.map((userAlbum) => mapUser(userAlbum.userInfo)) || [];
return {
albumName: entity.albumName,
albumThumbnailAssetId: entity.albumThumbnailAssetId,
createdAt: entity.createdAt,
id: entity.id,
ownerId: entity.ownerId,
sharedUsers,
shared: sharedUsers.length > 0,
assets: [],
assetCount: entity.assets?.length || 0,
}; };
} }

View File

@@ -10,7 +10,7 @@ export interface IServerVersion {
export const serverVersion: IServerVersion = { export const serverVersion: IServerVersion = {
major: 1, major: 1,
minor: 21, minor: 22,
patch: 0, patch: 0,
build: 0, build: 0,
}; };

File diff suppressed because one or more lines are too long

View File

@@ -90,6 +90,12 @@ export interface AdminSignupResponseDto {
* @interface AlbumResponseDto * @interface AlbumResponseDto
*/ */
export interface AlbumResponseDto { export interface AlbumResponseDto {
/**
*
* @type {number}
* @memberof AlbumResponseDto
*/
'assetCount': number;
/** /**
* *
* @type {string} * @type {string}

View File

@@ -6,85 +6,86 @@
@tailwind utilities; @tailwind utilities;
:root { :root {
font-family: 'Work Sans', sans-serif; font-family: 'Work Sans', sans-serif;
/* --immich-icon-button-hover-color: #d3d3d3; */ /* --immich-icon-button-hover-color: #d3d3d3; */
} }
html { html {
height: 100%; height: 100%;
width: 100%; width: 100%;
} }
html::-webkit-scrollbar { html::-webkit-scrollbar {
width: 8px; width: 8px;
} }
/* Track */ /* Track */
html::-webkit-scrollbar-track { html::-webkit-scrollbar-track {
background: #f1f1f1; background: #f1f1f1;
border-radius: 16px; border-radius: 16px;
} }
/* Handle */ /* Handle */
html::-webkit-scrollbar-thumb { html::-webkit-scrollbar-thumb {
background: rgba(85, 86, 87, 0.408); background: rgba(85, 86, 87, 0.408);
border-radius: 16px; border-radius: 16px;
} }
/* Handle on hover */ /* Handle on hover */
html::-webkit-scrollbar-thumb:hover { html::-webkit-scrollbar-thumb:hover {
background: #4250afad; background: #4250afad;
border-radius: 16px; border-radius: 16px;
} }
body { body {
/* min-height: 100vh; */ /* min-height: 100vh; */
margin: 0; margin: 0;
background-color: #f6f8fe; background-color: #f6f8fe;
color: #5f6368; color: #5f6368;
} }
input:focus-visible { input:focus-visible {
outline-offset: 0px !important; outline-offset: 0px !important;
outline: none !important; outline: none !important;
} }
@layer utilities { @layer utilities {
.immich-form-input { .immich-form-input {
@apply bg-slate-100 p-2 rounded-md focus:border-immich-primary text-sm; @apply bg-slate-100 p-2 rounded-md focus:border-immich-primary text-sm ;
} }
.immich-form-label { .immich-form-label {
@apply font-medium text-sm text-gray-500; @apply font-medium text-sm text-gray-500;
} }
.immich-btn-primary { .immich-btn-primary {
@apply bg-immich-primary text-gray-100 border rounded-xl py-2 px-4 transition-all duration-150 hover:bg-immich-primary hover:shadow-lg text-sm font-medium; @apply bg-immich-primary text-gray-100 border rounded-xl py-2 px-4 transition-all duration-150 hover:bg-immich-primary hover:shadow-lg text-sm font-medium;
} }
.immich-text-button { .immich-text-button {
@apply flex place-items-center place-content-center gap-2 hover:bg-immich-primary/5 p-2 rounded-lg font-medium; @apply flex place-items-center place-content-center gap-2 hover:bg-immich-primary/5 p-2 rounded-lg font-medium;
} }
/* width */ /* width */
.immich-scrollbar::-webkit-scrollbar { .immich-scrollbar::-webkit-scrollbar {
width: 8px; width: 8px;
} }
/* Track */ /* Track */
.immich-scrollbar::-webkit-scrollbar-track { .immich-scrollbar::-webkit-scrollbar-track {
background: #f1f1f1; background: #f1f1f1;
border-radius: 16px; border-radius: 16px;
} }
/* Handle */ /* Handle */
.immich-scrollbar::-webkit-scrollbar-thumb { .immich-scrollbar::-webkit-scrollbar-thumb {
background: rgba(85, 86, 87, 0.408); background: rgba(85, 86, 87, 0.408);
border-radius: 16px; border-radius: 16px;
} }
/* Handle on hover */ /* Handle on hover */
.immich-scrollbar::-webkit-scrollbar-thumb:hover { .immich-scrollbar::-webkit-scrollbar-thumb:hover {
background: #4250afad; background: #4250afad;
border-radius: 16px; border-radius: 16px;
} }
} }

View File

@@ -31,6 +31,9 @@
<td class="text-sm px-4 w-1/4 text-ellipsis">{user.lastName}</td> <td class="text-sm px-4 w-1/4 text-ellipsis">{user.lastName}</td>
<td class="text-sm px-4 w-1/4 text-ellipsis" <td class="text-sm px-4 w-1/4 text-ellipsis"
><button ><button
on:click={() => {
dispatch('edit-user', { user });
}}
class="bg-immich-primary text-gray-100 rounded-full p-3 transition-all duration-150 hover:bg-immich-primary/75" class="bg-immich-primary text-gray-100 rounded-full p-3 transition-all duration-150 hover:bg-immich-primary/75"
><PencilOutline size="20" /></button ><PencilOutline size="20" /></button
></td ></td
@@ -40,4 +43,4 @@
</tbody> </tbody>
</table> </table>
<button on:click={() => dispatch('createUser')} class="immich-btn-primary">Create user</button> <button on:click={() => dispatch('create-user')} class="immich-btn-primary">Create user</button>

View File

@@ -32,7 +32,7 @@
}; };
onMount(async () => { onMount(async () => {
imageData = await loadHighQualityThumbnail(album.albumThumbnailAssetId) || 'no-thumbnail.png'; imageData = (await loadHighQualityThumbnail(album.albumThumbnailAssetId)) || 'no-thumbnail.png';
}); });
</script> </script>
@@ -67,7 +67,7 @@
</p> </p>
<span class="text-xs flex gap-2"> <span class="text-xs flex gap-2">
<p>{album.assets.length} items</p> <p>{album.assetCount} items</p>
{#if album.shared} {#if album.shared}
<p>·</p> <p>·</p>

View File

@@ -61,7 +61,7 @@
$: { $: {
if (album.assets?.length < 6) { if (album.assets?.length < 6) {
thumbnailSize = Math.floor(viewWidth / album.assets.length - album.assets.length); thumbnailSize = Math.floor(viewWidth / album.assetCount - album.assetCount);
} else { } else {
thumbnailSize = Math.floor(viewWidth / 6 - 6); thumbnailSize = Math.floor(viewWidth / 6 - 6);
} }
@@ -69,7 +69,7 @@
const getDateRange = () => { const getDateRange = () => {
const startDate = new Date(album.assets[0].createdAt); const startDate = new Date(album.assets[0].createdAt);
const endDate = new Date(album.assets[album.assets.length - 1].createdAt); const endDate = new Date(album.assets[album.assetCount - 1].createdAt);
const timeFormatOption: Intl.DateTimeFormatOptions = { const timeFormatOption: Intl.DateTimeFormatOptions = {
month: 'short', month: 'short',
@@ -135,7 +135,7 @@
}; };
const navigateAssetForward = () => { const navigateAssetForward = () => {
try { try {
if (currentViewAssetIndex < album.assets.length - 1) { if (currentViewAssetIndex < album.assetCount - 1) {
currentViewAssetIndex++; currentViewAssetIndex++;
selectedAsset = album.assets[currentViewAssetIndex]; selectedAsset = album.assets[currentViewAssetIndex];
pushState(selectedAsset.id); pushState(selectedAsset.id);
@@ -296,7 +296,7 @@
{#if !isMultiSelectionMode} {#if !isMultiSelectionMode}
<ControlAppBar on:close-button-click={() => goto(backUrl)} backIcon={ArrowLeft}> <ControlAppBar on:close-button-click={() => goto(backUrl)} backIcon={ArrowLeft}>
<svelte:fragment slot="trailing"> <svelte:fragment slot="trailing">
{#if album.assets.length > 0} {#if album.assetCount > 0}
<CircleIconButton <CircleIconButton
title="Add Photos" title="Add Photos"
on:click={() => (isShowAssetSelection = true)} on:click={() => (isShowAssetSelection = true)}
@@ -322,7 +322,7 @@
{#if isCreatingSharedAlbum && album.sharedUsers.length == 0} {#if isCreatingSharedAlbum && album.sharedUsers.length == 0}
<button <button
disabled={album.assets.length == 0} disabled={album.assetCount == 0}
on:click={() => (isShowShareUserSelection = true)} on:click={() => (isShowShareUserSelection = true)}
class="immich-text-button border bg-immich-primary text-gray-50 hover:bg-immich-primary/75 px-6 text-sm disabled:opacity-25 disabled:bg-gray-500 disabled:cursor-not-allowed" class="immich-text-button border bg-immich-primary text-gray-50 hover:bg-immich-primary/75 px-6 text-sm disabled:opacity-25 disabled:bg-gray-500 disabled:cursor-not-allowed"
><span class="px-2">Share</span></button ><span class="px-2">Share</span></button
@@ -351,7 +351,7 @@
bind:this={titleInput} bind:this={titleInput}
/> />
{#if album.assets.length > 0} {#if album.assetCount > 0}
<p class="my-4 text-sm text-gray-500 font-medium">{getDateRange()}</p> <p class="my-4 text-sm text-gray-500 font-medium">{getDateRange()}</p>
{/if} {/if}
@@ -375,11 +375,11 @@
</div> </div>
{/if} {/if}
{#if album.assets.length > 0} {#if album.assetCount > 0}
<div class="flex flex-wrap gap-1 w-full pb-20" bind:clientWidth={viewWidth}> <div class="flex flex-wrap gap-1 w-full pb-20" bind:clientWidth={viewWidth}>
{#each album.assets as asset} {#each album.assets as asset}
{#key asset.id} {#key asset.id}
{#if album.assets.length < 7} {#if album.assetCount < 7}
<ImmichThumbnail <ImmichThumbnail
{asset} {asset}
{thumbnailSize} {thumbnailSize}

View File

@@ -1,122 +1,122 @@
<script lang="ts"> <script lang="ts">
import { api } from '@api'; import { api } from '@api';
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
let error: string; let error: string;
let success: string; let success: string;
let password: string = ''; let password = '';
let confirmPassowrd: string = ''; let confirmPassowrd = '';
let canCreateUser = false; let canCreateUser = false;
$: { $: {
if (password !== confirmPassowrd && confirmPassowrd.length > 0) { if (password !== confirmPassowrd && confirmPassowrd.length > 0) {
error = 'Password does not match'; error = 'Password does not match';
canCreateUser = false; canCreateUser = false;
} else { } else {
error = ''; error = '';
canCreateUser = true; canCreateUser = true;
} }
} }
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
async function registerUser(event: SubmitEvent) { async function registerUser(event: SubmitEvent) {
console.log('registerUser'); if (canCreateUser) {
if (canCreateUser) { error = '';
error = '';
const formElement = event.target as HTMLFormElement; const formElement = event.target as HTMLFormElement;
const form = new FormData(formElement); const form = new FormData(formElement);
const email = form.get('email'); const email = form.get('email');
const password = form.get('password'); const password = form.get('password');
const firstName = form.get('firstName'); const firstName = form.get('firstName');
const lastName = form.get('lastName'); const lastName = form.get('lastName');
const { status } = await api.userApi.createUser({ const {status} = await api.userApi.createUser({
email: String(email), email: String(email),
password: String(password), password: String(password),
firstName: String(firstName), firstName: String(firstName),
lastName: String(lastName) lastName: String(lastName)
}); });
if (status === 201) { if (status === 201) {
success = 'New user created'; success = 'New user created';
dispatch('user-created'); dispatch('user-created');
return; return;
} else { } else {
error = 'Error create user account'; error = 'Error create user account';
} }
} }
} }
</script> </script>
<div class="border bg-white p-4 shadow-sm w-[500px] rounded-md py-8"> <div class="border bg-white p-4 shadow-sm w-[500px] rounded-3xl py-8">
<div class="flex flex-col place-items-center place-content-center gap-4 px-4"> <div class="flex flex-col place-items-center place-content-center gap-4 px-4">
<img class="text-center" src="/immich-logo.svg" height="100" width="100" alt="immich-logo" /> <img class="text-center" src="/immich-logo.svg" height="100" width="100" alt="immich-logo"/>
<h1 class="text-2xl text-immich-primary font-medium">Create new user</h1> <h1 class="text-2xl text-immich-primary font-medium">Create new user</h1>
<p class="text-sm border rounded-md p-4 font-mono text-gray-600"> <p class="text-sm border rounded-md p-4 font-mono text-gray-600">
Please provide your user with the password, they will have to change it on their first sign Please provide your user with the password, they will have to change it on their first sign
in. in.
</p> </p>
</div> </div>
<form on:submit|preventDefault={registerUser} autocomplete="off"> <form on:submit|preventDefault={registerUser} autocomplete="off">
<div class="m-4 flex flex-col gap-2"> <div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="email">Email</label> <label class="immich-form-label" for="email">Email</label>
<input class="immich-form-input" id="email" name="email" type="email" required /> <input class="immich-form-input" id="email" name="email" type="email" required/>
</div> </div>
<div class="m-4 flex flex-col gap-2"> <div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="password">Password</label> <label class="immich-form-label" for="password">Password</label>
<input <input
class="immich-form-input" class="immich-form-input"
id="password" id="password"
name="password" name="password"
type="password" type="password"
required required
bind:value={password} bind:value={password}
/> />
</div> </div>
<div class="m-4 flex flex-col gap-2"> <div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="confirmPassword">Confirm Password</label> <label class="immich-form-label" for="confirmPassword">Confirm Password</label>
<input <input
class="immich-form-input" class="immich-form-input"
id="confirmPassword" id="confirmPassword"
name="password" name="password"
type="password" type="password"
required required
bind:value={confirmPassowrd} bind:value={confirmPassowrd}
/> />
</div> </div>
<div class="m-4 flex flex-col gap-2"> <div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="firstName">First Name</label> <label class="immich-form-label" for="firstName">First Name</label>
<input class="immich-form-input" id="firstName" name="firstName" type="text" required /> <input class="immich-form-input" id="firstName" name="firstName" type="text" required/>
</div> </div>
<div class="m-4 flex flex-col gap-2"> <div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="lastName">Last Name</label> <label class="immich-form-label" for="lastName">Last Name</label>
<input class="immich-form-input" id="lastName" name="lastName" type="text" required /> <input class="immich-form-input" id="lastName" name="lastName" type="text" required/>
</div> </div>
{#if error} {#if error}
<p class="text-red-400 ml-4 text-sm">{error}</p> <p class="text-red-400 ml-4 text-sm">{error}</p>
{/if} {/if}
{#if success} {#if success}
<p class="text-immich-primary ml-4 text-sm">{success}</p> <p class="text-immich-primary ml-4 text-sm">{success}</p>
{/if} {/if}
<div class="flex w-full"> <div class="flex w-full">
<button <button
type="submit" type="submit"
class="m-4 p-2 bg-immich-primary hover:bg-immich-primary/75 px-6 py-4 text-white rounded-md shadow-md w-full" class="m-4 bg-immich-primary hover:bg-immich-primary/75 px-6 py-3 text-white rounded-full shadow-md w-full font-medium"
>Create</button >Create
> </button
</div> >
</form> </div>
</form>
</div> </div>

View File

@@ -0,0 +1,103 @@
<script lang="ts">
import { api, UserResponseDto } from '@api';
import { createEventDispatcher } from 'svelte';
import AccountEditOutline from 'svelte-material-icons/AccountEditOutline.svelte';
export let user: UserResponseDto;
let error: string;
let success: string;
const dispatch = createEventDispatcher();
// eslint-disable-next-line no-undef
const editUser = async (event: SubmitEvent) => {
const formElement = event.target as HTMLFormElement;
const form = new FormData(formElement);
const firstName = form.get('firstName');
const lastName = form.get('lastName');
const {status} = await api.userApi.updateUser({
id: user.id,
firstName: firstName.toString(),
lastName: lastName.toString()
}).catch(e => console.log("Error updating user ", e));
if (status == 200) {
dispatch('edit-success');
}
}
const resetPassword = async () => {
const defaultPassword = 'password'
const {status} = await api.userApi.updateUser({
id: user.id,
password: defaultPassword,
shouldChangePassword: true,
}).catch(e => console.log("Error updating user ", e));
if (status == 200) {
dispatch('reset-password-success');
}
}
</script>
<div class="border bg-white p-4 shadow-sm w-[500px] rounded-3xl py-8">
<div class="flex flex-col place-items-center place-content-center gap-4 px-4">
<!-- <img class="text-center" src="/immich-logo.svg" height="100" width="100" alt="immich-logo"/>-->
<AccountEditOutline size="4em" color="#4250affe"/>
<h1 class="text-2xl text-immich-primary font-medium">Edit user</h1>
</div>
<form on:submit|preventDefault={editUser} autocomplete="off">
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="email">Email
(cannot change)</label>
<input class="immich-form-input disabled:bg-gray-200 hover:cursor-not-allowed"
id="email" name="email"
type="email" disabled
bind:value={user.email}/>
</div>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="firstName">First Name</label>
<input class="immich-form-input" id="firstName" name="firstName" type="text" required
bind:value={user.firstName}/>
</div>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="lastName">Last Name</label>
<input class="immich-form-input" id="lastName" name="lastName" type="text" required
bind:value={user.lastName}/>
</div>
{#if error}
<p class="text-red-400 ml-4 text-sm">{error}</p>
{/if}
{#if success}
<p class="text-immich-primary ml-4 text-sm">{success}</p>
{/if}
<div class="flex w-full px-4 gap-4 mt-8">
<button on:click={resetPassword}
class="flex-1 transition-colors bg-[#F9DEDC] hover:bg-red-50 text-[#410E0B] px-6 py-3 rounded-full w-full font-medium"
>Reset password
</button
>
<button
type="submit"
class="flex-1 transition-colors bg-immich-primary hover:bg-immich-primary/75 px-6 py-3 text-white rounded-full shadow-md w-full font-medium"
>Confirm
</button
>
</div>
</form>
</div>

View File

@@ -1,114 +1,181 @@
<script context="module" lang="ts"> <script context="module" lang="ts">
import type { Load } from '@sveltejs/kit'; import type { Load } from '@sveltejs/kit';
import { api, UserResponseDto } from '@api'; import { api, UserResponseDto } from '@api';
import { browser } from '$app/env';
export const load: Load = async ({ fetch, session }) => { export const load: Load = async ({fetch, session}) => {
if (!browser && !session.user) { if (!browser && !session.user) {
return { return {
status: 302, status: 302,
redirect: '/auth/login' redirect: '/auth/login'
}; };
} }
try { try {
const [user, allUsers] = await Promise.all([
fetch('/data/user/get-my-user-info').then((r) => r.json()),
fetch('/data/user/get-all-users?isAll=false').then((r) => r.json())
]);
return {
status: 200, const user: UserResponseDto = await fetch('/data/user/get-my-user-info').then((r) => r.json());
props: { const allUsers: UserResponseDto[] = await fetch<UserResponseDto[]>('/data/user/get-all-users?isAll=false').then((r) => r.json());
user: user,
allUsers: allUsers if (!user.isAdmin) {
} return {
}; status: 302,
} catch (e) { redirect: '/photos'
return { };
status: 302, }
redirect: '/auth/login'
}; return {
} status: 200,
}; props: {
user: user,
allUsers: allUsers
}
};
} catch (e) {
return {
status: 302,
redirect: '/auth/login'
};
}
};
</script> </script>
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import type { ImmichUser } from '$lib/models/immich-user'; import { AdminSideBarSelection } from '$lib/models/admin-sidebar-selection';
import { AdminSideBarSelection } from '$lib/models/admin-sidebar-selection'; import SideBarButton from '$lib/components/shared-components/side-bar/side-bar-button.svelte';
import SideBarButton from '$lib/components/shared-components/side-bar/side-bar-button.svelte'; import AccountMultipleOutline from 'svelte-material-icons/AccountMultipleOutline.svelte';
import AccountMultipleOutline from 'svelte-material-icons/AccountMultipleOutline.svelte'; import NavigationBar from '$lib/components/shared-components/navigation-bar.svelte';
import NavigationBar from '$lib/components/shared-components/navigation-bar.svelte'; import UserManagement from '$lib/components/admin-page/user-management.svelte';
import UserManagement from '$lib/components/admin-page/user-management.svelte'; import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte'; import CreateUserForm from '$lib/components/forms/create-user-form.svelte';
import CreateUserForm from '$lib/components/forms/create-user-form.svelte'; import EditUserForm from '$lib/components/forms/edit-user-form.svelte';
import StatusBox from '$lib/components/shared-components/status-box.svelte'; import StatusBox from '$lib/components/shared-components/status-box.svelte';
import { browser } from '$app/env';
let selectedAction: AdminSideBarSelection = AdminSideBarSelection.USER_MANAGEMENT;
export let user: ImmichUser; let selectedAction: AdminSideBarSelection = AdminSideBarSelection.USER_MANAGEMENT;
export let allUsers: UserResponseDto[];
let shouldShowCreateUserForm: boolean; export let user: UserResponseDto;
export let allUsers: UserResponseDto[];
const onButtonClicked = (buttonType: CustomEvent) => { let editUser: UserResponseDto;
selectedAction = buttonType.detail['actionType'] as AdminSideBarSelection;
};
onMount(() => { let shouldShowEditUserForm = false;
selectedAction = AdminSideBarSelection.USER_MANAGEMENT; let shouldShowCreateUserForm = false;
}); let shouldShowInfoPanel = false;
const onUserCreated = async () => { const onButtonClicked = (buttonType: CustomEvent) => {
const { data } = await api.userApi.getAllUsers(false); selectedAction = buttonType.detail['actionType'] as AdminSideBarSelection;
allUsers = data; };
shouldShowCreateUserForm = false; onMount(() => {
}; selectedAction = AdminSideBarSelection.USER_MANAGEMENT;
});
const onUserCreated = async () => {
const {data} = await api.userApi.getAllUsers(false);
allUsers = data;
shouldShowCreateUserForm = false;
};
const editUserHandler = async (event: CustomEvent) => {
const {user} = event.detail;
editUser = user;
shouldShowEditUserForm = true;
};
const onEditUserSuccess = async () => {
const {data} = await api.userApi.getAllUsers(false);
allUsers = data;
shouldShowEditUserForm = false;
}
const onEditPasswordSuccess = async () => {
const {data} = await api.userApi.getAllUsers(false);
allUsers = data;
shouldShowEditUserForm = false;
shouldShowInfoPanel = true;
}
</script> </script>
<svelte:head> <svelte:head>
<title>Administration - Immich</title> <title>Administration - Immich</title>
</svelte:head> </svelte:head>
<NavigationBar {user} /> <NavigationBar {user}/>
{#if shouldShowCreateUserForm} {#if shouldShowCreateUserForm}
<FullScreenModal on:clickOutside={() => (shouldShowCreateUserForm = false)}> <FullScreenModal on:clickOutside={() => (shouldShowCreateUserForm = false)}>
<div> <CreateUserForm on:user-created={onUserCreated}/>
<CreateUserForm on:user-created={onUserCreated} /> </FullScreenModal>
</div>
</FullScreenModal>
{/if} {/if}
{#if shouldShowEditUserForm}
<FullScreenModal on:clickOutside={() => (shouldShowEditUserForm = false)}>
<EditUserForm user={editUser} on:edit-success={onEditUserSuccess}
on:reset-password-success={onEditPasswordSuccess}/>
</FullScreenModal>
{/if}
{#if shouldShowInfoPanel}
<FullScreenModal on:clickOutside={() => (shouldShowInfoPanel = false)}>
<div class="border bg-white shadow-sm w-[500px] rounded-3xl p-8 text-sm">
<h1 class="font-bold text-immich-primary text-lg mb-4">Password reset success</h1>
<p>
The user's password has been reset to the default <code
class="font-bold bg-gray-200 px-2 py-1 rounded-md text-immich-primary">password</code>
<br>
Please inform the user, and they will need to change the password at the next log-on.
</p>
<div class="flex w-full">
<button
on:click={() => shouldShowInfoPanel = false}
class="mt-6 bg-immich-primary hover:bg-immich-primary/75 px-6 py-3 text-white rounded-full shadow-md w-full font-medium"
>Done
</button
>
</div>
</div>
</FullScreenModal>
{/if}
<section class="grid grid-cols-[250px_auto] relative pt-[72px] h-screen"> <section class="grid grid-cols-[250px_auto] relative pt-[72px] h-screen">
<section id="admin-sidebar" class="pt-8 pr-6 flex flex-col"> <section id="admin-sidebar" class="pt-8 pr-6 flex flex-col">
<SideBarButton <SideBarButton
title="User" title="User"
logo={AccountMultipleOutline} logo={AccountMultipleOutline}
actionType={AdminSideBarSelection.USER_MANAGEMENT} actionType={AdminSideBarSelection.USER_MANAGEMENT}
isSelected={selectedAction === AdminSideBarSelection.USER_MANAGEMENT} isSelected={selectedAction === AdminSideBarSelection.USER_MANAGEMENT}
on:selected={onButtonClicked} on:selected={onButtonClicked}
/> />
<div class="mb-6 mt-auto"> <div class="mb-6 mt-auto">
<StatusBox /> <StatusBox/>
</div> </div>
</section> </section>
<section class="overflow-y-auto relative"> <section class="overflow-y-auto relative">
<div id="setting-title" class="pt-10 fixed w-full z-50 bg-immich-bg"> <div id="setting-title" class="pt-10 fixed w-full z-50 bg-immich-bg">
<h1 class="text-lg ml-8 mb-4 text-immich-primary font-medium">{selectedAction}</h1> <h1 class="text-lg ml-8 mb-4 text-immich-primary font-medium">{selectedAction}</h1>
<hr /> <hr/>
</div> </div>
<section id="setting-content" class="relative pt-[85px] flex place-content-center">
<section class="w-[800px] pt-4">
{#if selectedAction === AdminSideBarSelection.USER_MANAGEMENT}
<UserManagement
{allUsers}
on:create-user={() => (shouldShowCreateUserForm = true)}
on:edit-user={editUserHandler}
/>
{/if}
</section>
</section>
</section>
<section id="setting-content" class="relative pt-[85px] flex place-content-center">
<section class="w-[800px] pt-4">
{#if selectedAction === AdminSideBarSelection.USER_MANAGEMENT}
<UserManagement {allUsers} on:createUser={() => (shouldShowCreateUserForm = true)} />
{/if}
</section>
</section>
</section>
</section> </section>

View File

@@ -61,7 +61,7 @@
// Delete album that has no photos and is named 'Untitled' // Delete album that has no photos and is named 'Untitled'
for (const album of albums) { for (const album of albums) {
if (album.albumName === 'Untitled' && album.assets.length === 0) { if (album.albumName === 'Untitled' && album.assetCount === 0) {
const isDeleted = await autoDeleteAlbum(album); const isDeleted = await autoDeleteAlbum(album);
if (isDeleted) { if (isDeleted) {

View File

@@ -1,56 +1,58 @@
<script context="module" lang="ts"> <script context="module" lang="ts">
export const prerender = false; export const prerender = false;
import type { Load } from '@sveltejs/kit'; import type { Load } from '@sveltejs/kit';
import { api } from '@api'; import { api } from '@api';
import { browser } from '$app/env';
export const load: Load = async () => { export const load: Load = async () => {
if (browser) { if (browser) {
try { try {
const { data: user } = await api.userApi.getMyUserInfo(); const {data: user} = await api.userApi.getMyUserInfo();
return { return {
status: 302, status: 302,
redirect: '/photos' redirect: '/photos'
}; };
} catch (e) {} } catch (e) {
}
const { data } = await api.userApi.getUserCount(); const {data} = await api.userApi.getUserCount();
return { return {
status: 200, status: 200,
props: { props: {
isAdminUserExist: data.userCount == 0 ? false : true isAdminUserExist: data.userCount != 0
} }
}; };
} }
}; };
</script> </script>
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { browser } from '$app/env';
export let isAdminUserExist: boolean; export let isAdminUserExist: boolean;
async function onGettingStartedClicked() { async function onGettingStartedClicked() {
isAdminUserExist ? goto('/auth/login') : goto('/auth/register'); isAdminUserExist ? await goto('/auth/login') : await goto('/auth/register');
} }
</script> </script>
<svelte:head> <svelte:head>
<title>Welcome 🎉 - Immich</title> <title>Welcome 🎉 - Immich</title>
<meta name="description" content="Immich Web Interface" /> <meta name="description" content="Immich Web Interface"/>
</svelte:head> </svelte:head>
<section class="h-screen w-screen flex place-items-center place-content-center"> <section class="h-screen w-screen flex place-items-center place-content-center">
<div class="flex flex-col place-items-center gap-8 text-center max-w-[350px]"> <div class="flex flex-col place-items-center gap-8 text-center max-w-[350px]">
<div class="flex place-items-center place-content-center "> <div class="flex place-items-center place-content-center ">
<img class="text-center" src="immich-logo.svg" height="200" width="200" alt="immich-logo" /> <img class="text-center" src="immich-logo.svg" height="200" width="200" alt="immich-logo"/>
</div> </div>
<h1 class="text-4xl text-immich-primary font-bold font-immich-title">Welcome to IMMICH Web</h1> <h1 class="text-4xl text-immich-primary font-bold font-immich-title">Welcome to IMMICH Web</h1>
<button <button
class="border px-4 py-2 rounded-md bg-immich-primary hover:bg-immich-primary/75 text-white font-bold w-[200px]" class="border px-4 py-2 rounded-md bg-immich-primary hover:bg-immich-primary/75 text-white font-bold w-[200px]"
on:click={onGettingStartedClicked}>Getting Started</button on:click={onGettingStartedClicked}>Getting Started
> </button
</div> >
</div>
</section> </section>

View File

@@ -254,7 +254,7 @@
> >
<!-- Date group title --> <!-- Date group title -->
<p class="font-medium text-sm text-immich-fg mb-2 flex place-items-center h-6"> <p class="font-medium text-sm text-immich-fg mb-2 flex place-items-center h-6">
{#if (selectedGroupThumbnail === groupIndex && isMouseOverGroup) || isMultiSelectionMode} {#if (selectedGroupThumbnail === groupIndex && isMouseOverGroup) || selectedGroup.has(groupIndex)}
<div <div
in:fly={{ x: -24, duration: 200, opacity: 0.5 }} in:fly={{ x: -24, duration: 200, opacity: 0.5 }}
out:fly={{ x: -24, duration: 200 }} out:fly={{ x: -24, duration: 200 }}

View File

@@ -1,119 +1,120 @@
<script context="module" lang="ts"> <script context="module" lang="ts">
export const prerender = false; export const prerender = false;
import type { Load } from '@sveltejs/kit'; import type { Load } from '@sveltejs/kit';
import { AlbumResponseDto, api, UserResponseDto } from '@api'; import { AlbumResponseDto, api, UserResponseDto } from '@api';
import { browser } from '$app/env';
export const load: Load = async ({ fetch, session }) => { export const load: Load = async ({fetch, session}) => {
if (!browser && !session.user) { if (!browser && !session.user) {
return { return {
status: 302, status: 302,
redirect: '/auth/login' redirect: '/auth/login'
}; };
} }
try { try {
const [user, sharedAlbums] = await Promise.all([ const [user, sharedAlbums] = await Promise.all([
fetch('/data/user/get-my-user-info').then((r) => r.json()), fetch('/data/user/get-my-user-info').then((r) => r.json()),
fetch('/data/album/get-all-albums?isShared=true').then((r) => r.json()) fetch('/data/album/get-all-albums?isShared=true').then((r) => r.json())
]); ]);
return { return {
status: 200, status: 200,
props: { props: {
user: user, user: user,
sharedAlbums: sharedAlbums sharedAlbums: sharedAlbums
} }
}; };
} catch (e) { } catch (e) {
return { return {
status: 302, status: 302,
redirect: '/auth/login' redirect: '/auth/login'
}; };
} }
}; };
</script> </script>
<script lang="ts"> <script lang="ts">
import NavigationBar from '$lib/components/shared-components/navigation-bar.svelte'; import NavigationBar from '$lib/components/shared-components/navigation-bar.svelte';
import SideBar from '$lib/components/shared-components/side-bar/side-bar.svelte'; import SideBar from '$lib/components/shared-components/side-bar/side-bar.svelte';
import PlusBoxOutline from 'svelte-material-icons/PlusBoxOutline.svelte'; import PlusBoxOutline from 'svelte-material-icons/PlusBoxOutline.svelte';
import SharedAlbumListTile from '$lib/components/sharing-page/shared-album-list-tile.svelte'; import SharedAlbumListTile from '$lib/components/sharing-page/shared-album-list-tile.svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { browser } from '$app/env';
export let user: UserResponseDto; export let user: UserResponseDto;
export let sharedAlbums: AlbumResponseDto[]; export let sharedAlbums: AlbumResponseDto[];
const createSharedAlbum = async () => { const createSharedAlbum = async () => {
try { try {
const { data: newAlbum } = await api.albumApi.createAlbum({ const {data: newAlbum} = await api.albumApi.createAlbum({
albumName: 'Untitled' albumName: 'Untitled'
}); });
goto('/albums/' + newAlbum.id); goto('/albums/' + newAlbum.id);
} catch (e) { } catch (e) {
console.log('Error [createAlbum] ', e); console.log('Error [createAlbum] ', e);
} }
}; };
</script> </script>
<svelte:head> <svelte:head>
<title>Albums - Immich</title> <title>Albums - Immich</title>
</svelte:head> </svelte:head>
<section> <section>
<NavigationBar {user} on:uploadClicked={() => {}} /> <NavigationBar {user} on:uploadClicked={() => {}}/>
</section> </section>
<section class="grid grid-cols-[250px_auto] relative pt-[72px] h-screen bg-immich-bg"> <section class="grid grid-cols-[250px_auto] relative pt-[72px] h-screen bg-immich-bg">
<SideBar /> <SideBar/>
<section class="overflow-y-auto relative"> <section class="overflow-y-auto relative">
<section id="album-content" class="relative pt-8 pl-4 mb-12 bg-immich-bg"> <section id="album-content" class="relative pt-8 pl-4 mb-12 bg-immich-bg">
<!-- Main Section --> <!-- Main Section -->
<div class="px-4 flex justify-between place-items-center"> <div class="px-4 flex justify-between place-items-center">
<div> <div>
<p class="font-medium">Sharing</p> <p class="font-medium">Sharing</p>
</div> </div>
<div> <div>
<button <button
on:click={createSharedAlbum} on:click={createSharedAlbum}
class="flex place-items-center gap-1 text-sm hover:bg-immich-primary/5 p-2 rounded-lg font-medium hover:text-gray-700" class="flex place-items-center gap-1 text-sm hover:bg-immich-primary/5 p-2 rounded-lg font-medium hover:text-gray-700"
> >
<span> <span>
<PlusBoxOutline size="18" /> <PlusBoxOutline size="18"/>
</span> </span>
<p>Create shared album</p> <p>Create shared album</p>
</button> </button>
</div> </div>
</div> </div>
<div class="my-4"> <div class="my-4">
<hr /> <hr/>
</div> </div>
<!-- Share Album List --> <!-- Share Album List -->
<div class="w-full flex flex-col place-items-center"> <div class="w-full flex flex-col place-items-center">
{#each sharedAlbums as album} {#each sharedAlbums as album}
<a sveltekit:prefetch href={`albums/${album.id}`}> <a sveltekit:prefetch href={`albums/${album.id}`}>
<SharedAlbumListTile {album} {user} /></a <SharedAlbumListTile {album} {user}/>
> </a
{/each} >
</div> {/each}
</div>
<!-- Empty List --> <!-- Empty List -->
{#if sharedAlbums.length === 0} {#if sharedAlbums.length === 0}
<div <div
class="border p-5 w-[50%] m-auto mt-10 bg-gray-50 rounded-3xl flex flex-col place-content-center place-items-center" class="border p-5 w-[50%] m-auto mt-10 bg-gray-50 rounded-3xl flex flex-col place-content-center place-items-center"
> >
<img src="/empty-2.svg" alt="Empty shared album" width="500" /> <img src="/empty-2.svg" alt="Empty shared album" width="500"/>
<p class="text-center text-immich-text-gray-500"> <p class="text-center text-immich-text-gray-500">
Create a shared album to share photos and videos with people in your network Create a shared album to share photos and videos with people in your network
</p> </p>
</div> </div>
{/if} {/if}
</section> </section>
</section> </section>
</section> </section>