forked from Cutlery/immich
Merge branch 'main' into fix/edit-faces-notification
This commit is contained in:
commit
86b200e870
@ -11,7 +11,7 @@ Unable to set `app.immich:/` as a valid redirect URI? See [Mobile Redirect URI](
|
|||||||
Immich supports 3rd party authentication via [OpenID Connect][oidc] (OIDC), an identity layer built on top of OAuth2. OIDC is supported by most identity providers, including:
|
Immich supports 3rd party authentication via [OpenID Connect][oidc] (OIDC), an identity layer built on top of OAuth2. OIDC is supported by most identity providers, including:
|
||||||
|
|
||||||
- [Authentik](https://goauthentik.io/integrations/sources/oauth/#openid-connect)
|
- [Authentik](https://goauthentik.io/integrations/sources/oauth/#openid-connect)
|
||||||
- [Authelia](https://www.authelia.com/configuration/identity-providers/open-id-connect/)
|
- [Authelia](https://www.authelia.com/configuration/identity-providers/openid-connect/clients/)
|
||||||
- [Okta](https://www.okta.com/openid-connect/)
|
- [Okta](https://www.okta.com/openid-connect/)
|
||||||
- [Google](https://developers.google.com/identity/openid-connect/openid-connect)
|
- [Google](https://developers.google.com/identity/openid-connect/openid-connect)
|
||||||
|
|
||||||
|
6
e2e/package-lock.json
generated
6
e2e/package-lock.json
generated
@ -1158,9 +1158,9 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "20.11.25",
|
"version": "20.11.27",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.25.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.27.tgz",
|
||||||
"integrity": "sha512-TBHyJxk2b7HceLVGFcpAUjsa5zIdsPWlR6XHfyGzd0SFu+/NFgQgMAl96MSDZgQDvJAvV6BKsFOrt6zIL09JDw==",
|
"integrity": "sha512-qyUZfMnCg1KEz57r7pzFtSGt49f6RPkPBis3Vo4PbS7roQEDn22hiHzl/Lo1q4i4hDEgBJmBF/NTNg2XR0HbFg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~5.26.4"
|
"undici-types": "~5.26.4"
|
||||||
|
@ -27,7 +27,7 @@ describe('/library', () => {
|
|||||||
await utils.resetDatabase();
|
await utils.resetDatabase();
|
||||||
admin = await utils.adminSetup();
|
admin = await utils.adminSetup();
|
||||||
user = await utils.userSetup(admin.accessToken, userDto.user1);
|
user = await utils.userSetup(admin.accessToken, userDto.user1);
|
||||||
library = await utils.createLibrary(admin.accessToken, { type: LibraryType.External });
|
library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, type: LibraryType.External });
|
||||||
websocket = await utils.connectWebsocket(admin.accessToken);
|
websocket = await utils.connectWebsocket(admin.accessToken);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -82,7 +82,7 @@ describe('/library', () => {
|
|||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.post('/library')
|
.post('/library')
|
||||||
.set('Authorization', `Bearer ${user.accessToken}`)
|
.set('Authorization', `Bearer ${user.accessToken}`)
|
||||||
.send({ type: LibraryType.External });
|
.send({ ownerId: admin.userId, type: LibraryType.External });
|
||||||
|
|
||||||
expect(status).toBe(403);
|
expect(status).toBe(403);
|
||||||
expect(body).toEqual(errorDto.forbidden);
|
expect(body).toEqual(errorDto.forbidden);
|
||||||
@ -92,7 +92,7 @@ describe('/library', () => {
|
|||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.post('/library')
|
.post('/library')
|
||||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||||
.send({ type: LibraryType.External });
|
.send({ ownerId: admin.userId, type: LibraryType.External });
|
||||||
|
|
||||||
expect(status).toBe(201);
|
expect(status).toBe(201);
|
||||||
expect(body).toEqual(
|
expect(body).toEqual(
|
||||||
@ -113,6 +113,7 @@ describe('/library', () => {
|
|||||||
.post('/library')
|
.post('/library')
|
||||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||||
.send({
|
.send({
|
||||||
|
ownerId: admin.userId,
|
||||||
type: LibraryType.External,
|
type: LibraryType.External,
|
||||||
name: 'My Awesome Library',
|
name: 'My Awesome Library',
|
||||||
importPaths: ['/path/to/import'],
|
importPaths: ['/path/to/import'],
|
||||||
@ -133,6 +134,7 @@ describe('/library', () => {
|
|||||||
.post('/library')
|
.post('/library')
|
||||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||||
.send({
|
.send({
|
||||||
|
ownerId: admin.userId,
|
||||||
type: LibraryType.External,
|
type: LibraryType.External,
|
||||||
name: 'My Awesome Library',
|
name: 'My Awesome Library',
|
||||||
importPaths: ['/path', '/path'],
|
importPaths: ['/path', '/path'],
|
||||||
@ -148,6 +150,7 @@ describe('/library', () => {
|
|||||||
.post('/library')
|
.post('/library')
|
||||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||||
.send({
|
.send({
|
||||||
|
ownerId: admin.userId,
|
||||||
type: LibraryType.External,
|
type: LibraryType.External,
|
||||||
name: 'My Awesome Library',
|
name: 'My Awesome Library',
|
||||||
importPaths: ['/path/to/import'],
|
importPaths: ['/path/to/import'],
|
||||||
@ -162,7 +165,7 @@ describe('/library', () => {
|
|||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.post('/library')
|
.post('/library')
|
||||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||||
.send({ type: LibraryType.Upload });
|
.send({ ownerId: admin.userId, type: LibraryType.Upload });
|
||||||
|
|
||||||
expect(status).toBe(201);
|
expect(status).toBe(201);
|
||||||
expect(body).toEqual(
|
expect(body).toEqual(
|
||||||
@ -182,7 +185,7 @@ describe('/library', () => {
|
|||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.post('/library')
|
.post('/library')
|
||||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||||
.send({ type: LibraryType.Upload, name: 'My Awesome Library' });
|
.send({ ownerId: admin.userId, type: LibraryType.Upload, name: 'My Awesome Library' });
|
||||||
|
|
||||||
expect(status).toBe(201);
|
expect(status).toBe(201);
|
||||||
expect(body).toEqual(
|
expect(body).toEqual(
|
||||||
@ -196,7 +199,7 @@ describe('/library', () => {
|
|||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.post('/library')
|
.post('/library')
|
||||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||||
.send({ type: LibraryType.Upload, importPaths: ['/path/to/import'] });
|
.send({ ownerId: admin.userId, type: LibraryType.Upload, importPaths: ['/path/to/import'] });
|
||||||
|
|
||||||
expect(status).toBe(400);
|
expect(status).toBe(400);
|
||||||
expect(body).toEqual(errorDto.badRequest('Upload libraries cannot have import paths'));
|
expect(body).toEqual(errorDto.badRequest('Upload libraries cannot have import paths'));
|
||||||
@ -206,7 +209,7 @@ describe('/library', () => {
|
|||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.post('/library')
|
.post('/library')
|
||||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||||
.send({ type: LibraryType.Upload, exclusionPatterns: ['**/Raw/**'] });
|
.send({ ownerId: admin.userId, type: LibraryType.Upload, exclusionPatterns: ['**/Raw/**'] });
|
||||||
|
|
||||||
expect(status).toBe(400);
|
expect(status).toBe(400);
|
||||||
expect(body).toEqual(errorDto.badRequest('Upload libraries cannot have exclusion patterns'));
|
expect(body).toEqual(errorDto.badRequest('Upload libraries cannot have exclusion patterns'));
|
||||||
@ -330,7 +333,10 @@ describe('/library', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should get library by id', async () => {
|
it('should get library by id', async () => {
|
||||||
const library = await utils.createLibrary(admin.accessToken, { type: LibraryType.External });
|
const library = await utils.createLibrary(admin.accessToken, {
|
||||||
|
ownerId: admin.userId,
|
||||||
|
type: LibraryType.External,
|
||||||
|
});
|
||||||
|
|
||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.get(`/library/${library.id}`)
|
.get(`/library/${library.id}`)
|
||||||
@ -386,7 +392,10 @@ describe('/library', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should delete an external library', async () => {
|
it('should delete an external library', async () => {
|
||||||
const library = await utils.createLibrary(admin.accessToken, { type: LibraryType.External });
|
const library = await utils.createLibrary(admin.accessToken, {
|
||||||
|
ownerId: admin.userId,
|
||||||
|
type: LibraryType.External,
|
||||||
|
});
|
||||||
|
|
||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.delete(`/library/${library.id}`)
|
.delete(`/library/${library.id}`)
|
||||||
@ -407,6 +416,7 @@ describe('/library', () => {
|
|||||||
|
|
||||||
it('should delete an external library with assets', async () => {
|
it('should delete an external library with assets', async () => {
|
||||||
const library = await utils.createLibrary(admin.accessToken, {
|
const library = await utils.createLibrary(admin.accessToken, {
|
||||||
|
ownerId: admin.userId,
|
||||||
type: LibraryType.External,
|
type: LibraryType.External,
|
||||||
importPaths: [`${testAssetDirInternal}/temp`],
|
importPaths: [`${testAssetDirInternal}/temp`],
|
||||||
});
|
});
|
||||||
@ -455,6 +465,7 @@ describe('/library', () => {
|
|||||||
|
|
||||||
it('should not scan an upload library', async () => {
|
it('should not scan an upload library', async () => {
|
||||||
const library = await utils.createLibrary(admin.accessToken, {
|
const library = await utils.createLibrary(admin.accessToken, {
|
||||||
|
ownerId: admin.userId,
|
||||||
type: LibraryType.Upload,
|
type: LibraryType.Upload,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -468,6 +479,7 @@ describe('/library', () => {
|
|||||||
|
|
||||||
it('should scan external library', async () => {
|
it('should scan external library', async () => {
|
||||||
const library = await utils.createLibrary(admin.accessToken, {
|
const library = await utils.createLibrary(admin.accessToken, {
|
||||||
|
ownerId: admin.userId,
|
||||||
type: LibraryType.External,
|
type: LibraryType.External,
|
||||||
importPaths: [`${testAssetDirInternal}/temp/directoryA`],
|
importPaths: [`${testAssetDirInternal}/temp/directoryA`],
|
||||||
});
|
});
|
||||||
@ -483,6 +495,7 @@ describe('/library', () => {
|
|||||||
|
|
||||||
it('should scan external library with exclusion pattern', async () => {
|
it('should scan external library with exclusion pattern', async () => {
|
||||||
const library = await utils.createLibrary(admin.accessToken, {
|
const library = await utils.createLibrary(admin.accessToken, {
|
||||||
|
ownerId: admin.userId,
|
||||||
type: LibraryType.External,
|
type: LibraryType.External,
|
||||||
importPaths: [`${testAssetDirInternal}/temp`],
|
importPaths: [`${testAssetDirInternal}/temp`],
|
||||||
exclusionPatterns: ['**/directoryA'],
|
exclusionPatterns: ['**/directoryA'],
|
||||||
@ -499,6 +512,7 @@ describe('/library', () => {
|
|||||||
|
|
||||||
it('should scan multiple import paths', async () => {
|
it('should scan multiple import paths', async () => {
|
||||||
const library = await utils.createLibrary(admin.accessToken, {
|
const library = await utils.createLibrary(admin.accessToken, {
|
||||||
|
ownerId: admin.userId,
|
||||||
type: LibraryType.External,
|
type: LibraryType.External,
|
||||||
importPaths: [`${testAssetDirInternal}/temp/directoryA`, `${testAssetDirInternal}/temp/directoryB`],
|
importPaths: [`${testAssetDirInternal}/temp/directoryA`, `${testAssetDirInternal}/temp/directoryB`],
|
||||||
});
|
});
|
||||||
@ -515,6 +529,7 @@ describe('/library', () => {
|
|||||||
|
|
||||||
it('should pick up new files', async () => {
|
it('should pick up new files', async () => {
|
||||||
const library = await utils.createLibrary(admin.accessToken, {
|
const library = await utils.createLibrary(admin.accessToken, {
|
||||||
|
ownerId: admin.userId,
|
||||||
type: LibraryType.External,
|
type: LibraryType.External,
|
||||||
importPaths: [`${testAssetDirInternal}/temp`],
|
importPaths: [`${testAssetDirInternal}/temp`],
|
||||||
});
|
});
|
||||||
|
19
install.sh
19
install.sh
@ -6,7 +6,7 @@ ip_address=$(hostname -I | awk '{print $1}')
|
|||||||
|
|
||||||
create_immich_directory() {
|
create_immich_directory() {
|
||||||
echo "Creating Immich directory..."
|
echo "Creating Immich directory..."
|
||||||
mkdir -p ./immich-app/immich-data
|
mkdir -p ./immich-app
|
||||||
cd ./immich-app || exit
|
cd ./immich-app || exit
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -20,21 +20,6 @@ download_dot_env_file() {
|
|||||||
curl -L https://github.com/immich-app/immich/releases/latest/download/example.env -o ./.env >/dev/null 2>&1
|
curl -L https://github.com/immich-app/immich/releases/latest/download/example.env -o ./.env >/dev/null 2>&1
|
||||||
}
|
}
|
||||||
|
|
||||||
replace_env_value() {
|
|
||||||
KERNEL="$(uname -s | tr '[:upper:]' '[:lower:]')"
|
|
||||||
if [ "$KERNEL" = "darwin" ]; then
|
|
||||||
sed -i '' "s|$1=.*|$1=$2|" ./.env
|
|
||||||
else
|
|
||||||
sed -i "s|$1=.*|$1=$2|" ./.env
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
populate_upload_location() {
|
|
||||||
echo "Populating default UPLOAD_LOCATION value..."
|
|
||||||
upload_location=$(pwd)/immich-data
|
|
||||||
replace_env_value "UPLOAD_LOCATION" "$upload_location"
|
|
||||||
}
|
|
||||||
|
|
||||||
start_docker_compose() {
|
start_docker_compose() {
|
||||||
echo "Starting Immich's docker containers"
|
echo "Starting Immich's docker containers"
|
||||||
|
|
||||||
@ -59,7 +44,6 @@ start_docker_compose() {
|
|||||||
show_friendly_message() {
|
show_friendly_message() {
|
||||||
echo "Successfully deployed Immich!"
|
echo "Successfully deployed Immich!"
|
||||||
echo "You can access the website at http://$ip_address:2283 and the server URL for the mobile app is http://$ip_address:2283/api"
|
echo "You can access the website at http://$ip_address:2283 and the server URL for the mobile app is http://$ip_address:2283/api"
|
||||||
echo "The library location is $upload_location"
|
|
||||||
echo "---------------------------------------------------"
|
echo "---------------------------------------------------"
|
||||||
echo "If you want to configure custom information of the server, including the database, Redis information, or the backup (or upload) location, etc.
|
echo "If you want to configure custom information of the server, including the database, Redis information, or the backup (or upload) location, etc.
|
||||||
|
|
||||||
@ -75,5 +59,4 @@ show_friendly_message() {
|
|||||||
create_immich_directory
|
create_immich_directory
|
||||||
download_docker_compose_file
|
download_docker_compose_file
|
||||||
download_dot_env_file
|
download_dot_env_file
|
||||||
populate_upload_location
|
|
||||||
start_docker_compose
|
start_docker_compose
|
||||||
|
1
mobile/assets/immich-logo.json
Normal file
1
mobile/assets/immich-logo.json
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -22,8 +22,11 @@ class ExifPeople extends ConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final peopleProvider =
|
final peopleProvider =
|
||||||
ref.watch(assetPeopleNotifierProvider(asset).notifier);
|
ref.watch(assetPeopleNotifierProvider(asset).notifier);
|
||||||
final people = ref.watch(assetPeopleNotifierProvider(asset));
|
final people = ref
|
||||||
final double imageSize = math.min(context.width / 3, 150);
|
.watch(assetPeopleNotifierProvider(asset))
|
||||||
|
.value
|
||||||
|
?.where((p) => !p.isHidden);
|
||||||
|
final double imageSize = math.min(context.width / 3, 120);
|
||||||
|
|
||||||
showPersonNameEditModel(
|
showPersonNameEditModel(
|
||||||
String personId,
|
String personId,
|
||||||
@ -40,15 +43,14 @@ class ExifPeople extends ConsumerWidget {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (people.value?.isEmpty ?? true) {
|
if (people?.isEmpty ?? true) {
|
||||||
// Empty list or loading
|
// Empty list or loading
|
||||||
return Container();
|
return Container();
|
||||||
}
|
}
|
||||||
|
|
||||||
final curatedPeople = people.value
|
final curatedPeople =
|
||||||
?.map((p) => CuratedContent(id: p.id, label: p.name))
|
people?.map((p) => CuratedContent(id: p.id, label: p.name)).toList() ??
|
||||||
.toList() ??
|
[];
|
||||||
[];
|
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
|
@ -5,11 +5,9 @@ import 'package:photo_manager/photo_manager.dart';
|
|||||||
class AvailableAlbum {
|
class AvailableAlbum {
|
||||||
final AssetPathEntity albumEntity;
|
final AssetPathEntity albumEntity;
|
||||||
final DateTime? lastBackup;
|
final DateTime? lastBackup;
|
||||||
final Uint8List? thumbnailData;
|
|
||||||
AvailableAlbum({
|
AvailableAlbum({
|
||||||
required this.albumEntity,
|
required this.albumEntity,
|
||||||
this.lastBackup,
|
this.lastBackup,
|
||||||
this.thumbnailData,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
AvailableAlbum copyWith({
|
AvailableAlbum copyWith({
|
||||||
@ -20,7 +18,6 @@ class AvailableAlbum {
|
|||||||
return AvailableAlbum(
|
return AvailableAlbum(
|
||||||
albumEntity: albumEntity ?? this.albumEntity,
|
albumEntity: albumEntity ?? this.albumEntity,
|
||||||
lastBackup: lastBackup ?? this.lastBackup,
|
lastBackup: lastBackup ?? this.lastBackup,
|
||||||
thumbnailData: thumbnailData ?? this.thumbnailData,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -34,7 +31,7 @@ class AvailableAlbum {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() =>
|
String toString() =>
|
||||||
'AvailableAlbum(albumEntity: $albumEntity, lastBackup: $lastBackup, thumbnailData: $thumbnailData)';
|
'AvailableAlbum(albumEntity: $albumEntity, lastBackup: $lastBackup)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) {
|
||||||
|
@ -234,33 +234,9 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
for (AssetPathEntity album in albums) {
|
for (AssetPathEntity album in albums) {
|
||||||
AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album);
|
AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album);
|
||||||
|
|
||||||
final assetCountInAlbum = await album.assetCountAsync;
|
availableAlbums.add(availableAlbum);
|
||||||
if (assetCountInAlbum > 0) {
|
|
||||||
final assetList = await album.getAssetListPaged(page: 0, size: 1);
|
|
||||||
|
|
||||||
// Even though we check assetCountInAlbum to make sure that there are assets in album
|
albumMap[album.id] = album;
|
||||||
// The `getAssetListPaged` method still return empty list and cause not assets get rendered
|
|
||||||
if (assetList.isEmpty) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
final thumbnailAsset = assetList.first;
|
|
||||||
try {
|
|
||||||
final thumbnailData = await thumbnailAsset
|
|
||||||
.thumbnailDataWithSize(const ThumbnailSize(512, 512));
|
|
||||||
availableAlbum =
|
|
||||||
availableAlbum.copyWith(thumbnailData: thumbnailData);
|
|
||||||
} catch (e, stack) {
|
|
||||||
log.severe(
|
|
||||||
"Failed to get thumbnail for album ${album.name}",
|
|
||||||
e,
|
|
||||||
stack,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
availableAlbums.add(availableAlbum);
|
|
||||||
|
|
||||||
albumMap[album.id] = album;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
state = state.copyWith(availableAlbums: availableAlbums);
|
state = state.copyWith(availableAlbums: availableAlbums);
|
||||||
|
|
||||||
|
@ -11,17 +11,16 @@ import 'package:immich_mobile/routing/router.dart';
|
|||||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||||
|
|
||||||
class AlbumInfoCard extends HookConsumerWidget {
|
class AlbumInfoCard extends HookConsumerWidget {
|
||||||
final Uint8List? imageData;
|
final AvailableAlbum album;
|
||||||
final AvailableAlbum albumInfo;
|
|
||||||
|
|
||||||
const AlbumInfoCard({super.key, this.imageData, required this.albumInfo});
|
const AlbumInfoCard({super.key, required this.album});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final bool isSelected =
|
final bool isSelected =
|
||||||
ref.watch(backupProvider).selectedBackupAlbums.contains(albumInfo);
|
ref.watch(backupProvider).selectedBackupAlbums.contains(album);
|
||||||
final bool isExcluded =
|
final bool isExcluded =
|
||||||
ref.watch(backupProvider).excludedBackupAlbums.contains(albumInfo);
|
ref.watch(backupProvider).excludedBackupAlbums.contains(album);
|
||||||
final isDarkTheme = context.isDarkTheme;
|
final isDarkTheme = context.isDarkTheme;
|
||||||
|
|
||||||
ColorFilter selectedFilter = ColorFilter.mode(
|
ColorFilter selectedFilter = ColorFilter.mode(
|
||||||
@ -82,9 +81,9 @@ class AlbumInfoCard extends HookConsumerWidget {
|
|||||||
HapticFeedback.selectionClick();
|
HapticFeedback.selectionClick();
|
||||||
|
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
ref.read(backupProvider.notifier).removeAlbumForBackup(albumInfo);
|
ref.read(backupProvider.notifier).removeAlbumForBackup(album);
|
||||||
} else {
|
} else {
|
||||||
ref.read(backupProvider.notifier).addAlbumForBackup(albumInfo);
|
ref.read(backupProvider.notifier).addAlbumForBackup(album);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDoubleTap: () {
|
onDoubleTap: () {
|
||||||
@ -92,13 +91,11 @@ class AlbumInfoCard extends HookConsumerWidget {
|
|||||||
|
|
||||||
if (isExcluded) {
|
if (isExcluded) {
|
||||||
// Remove from exclude album list
|
// Remove from exclude album list
|
||||||
ref
|
ref.read(backupProvider.notifier).removeExcludedAlbumForBackup(album);
|
||||||
.read(backupProvider.notifier)
|
|
||||||
.removeExcludedAlbumForBackup(albumInfo);
|
|
||||||
} else {
|
} else {
|
||||||
// Add to exclude album list
|
// Add to exclude album list
|
||||||
|
|
||||||
if (albumInfo.id == 'isAll' || albumInfo.name == 'Recents') {
|
if (album.id == 'isAll' || album.name == 'Recents') {
|
||||||
ImmichToast.show(
|
ImmichToast.show(
|
||||||
context: context,
|
context: context,
|
||||||
msg: 'Cannot exclude album contains all assets',
|
msg: 'Cannot exclude album contains all assets',
|
||||||
@ -108,9 +105,7 @@ class AlbumInfoCard extends HookConsumerWidget {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ref
|
ref.read(backupProvider.notifier).addExcludedAlbumForBackup(album);
|
||||||
.read(backupProvider.notifier)
|
|
||||||
.addExcludedAlbumForBackup(albumInfo);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Card(
|
child: Card(
|
||||||
@ -136,14 +131,12 @@ class AlbumInfoCard extends HookConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
ColorFiltered(
|
ColorFiltered(
|
||||||
colorFilter: buildImageFilter(),
|
colorFilter: buildImageFilter(),
|
||||||
child: Image(
|
child: const Image(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: double.infinity,
|
height: double.infinity,
|
||||||
image: imageData != null
|
image: AssetImage(
|
||||||
? MemoryImage(imageData!)
|
'assets/immich-logo.png',
|
||||||
: const AssetImage(
|
),
|
||||||
'assets/immich-logo.png',
|
|
||||||
) as ImageProvider,
|
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -168,7 +161,7 @@ class AlbumInfoCard extends HookConsumerWidget {
|
|||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
albumInfo.name,
|
album.name,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: context.primaryColor,
|
color: context.primaryColor,
|
||||||
@ -182,7 +175,7 @@ class AlbumInfoCard extends HookConsumerWidget {
|
|||||||
if (snapshot.hasData) {
|
if (snapshot.hasData) {
|
||||||
return Text(
|
return Text(
|
||||||
snapshot.data.toString() +
|
snapshot.data.toString() +
|
||||||
(albumInfo.isAll
|
(album.isAll
|
||||||
? " (${'backup_all'.tr()})"
|
? " (${'backup_all'.tr()})"
|
||||||
: ""),
|
: ""),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@ -193,7 +186,7 @@ class AlbumInfoCard extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
return const Text("0");
|
return const Text("0");
|
||||||
}),
|
}),
|
||||||
future: albumInfo.assetCount,
|
future: album.assetCount,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -202,7 +195,7 @@ class AlbumInfoCard extends HookConsumerWidget {
|
|||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
context.pushRoute(
|
context.pushRoute(
|
||||||
AlbumPreviewRoute(album: albumInfo.albumEntity),
|
AlbumPreviewRoute(album: album.albumEntity),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
|
@ -11,47 +11,26 @@ import 'package:immich_mobile/routing/router.dart';
|
|||||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||||
|
|
||||||
class AlbumInfoListTile extends HookConsumerWidget {
|
class AlbumInfoListTile extends HookConsumerWidget {
|
||||||
final Uint8List? imageData;
|
final AvailableAlbum album;
|
||||||
final AvailableAlbum albumInfo;
|
|
||||||
|
|
||||||
const AlbumInfoListTile({super.key, this.imageData, required this.albumInfo});
|
const AlbumInfoListTile({super.key, required this.album});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final bool isSelected =
|
final bool isSelected =
|
||||||
ref.watch(backupProvider).selectedBackupAlbums.contains(albumInfo);
|
ref.watch(backupProvider).selectedBackupAlbums.contains(album);
|
||||||
final bool isExcluded =
|
final bool isExcluded =
|
||||||
ref.watch(backupProvider).excludedBackupAlbums.contains(albumInfo);
|
ref.watch(backupProvider).excludedBackupAlbums.contains(album);
|
||||||
|
|
||||||
ColorFilter selectedFilter = ColorFilter.mode(
|
|
||||||
context.primaryColor.withAlpha(100),
|
|
||||||
BlendMode.darken,
|
|
||||||
);
|
|
||||||
ColorFilter excludedFilter =
|
|
||||||
ColorFilter.mode(Colors.red.withAlpha(75), BlendMode.darken);
|
|
||||||
ColorFilter unselectedFilter =
|
|
||||||
const ColorFilter.mode(Colors.black, BlendMode.color);
|
|
||||||
|
|
||||||
var assetCount = useState(0);
|
var assetCount = useState(0);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() {
|
() {
|
||||||
albumInfo.assetCount.then((value) => assetCount.value = value);
|
album.assetCount.then((value) => assetCount.value = value);
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
[albumInfo],
|
[album],
|
||||||
);
|
);
|
||||||
|
|
||||||
buildImageFilter() {
|
|
||||||
if (isSelected) {
|
|
||||||
return selectedFilter;
|
|
||||||
} else if (isExcluded) {
|
|
||||||
return excludedFilter;
|
|
||||||
} else {
|
|
||||||
return unselectedFilter;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
buildTileColor() {
|
buildTileColor() {
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
return context.isDarkTheme
|
return context.isDarkTheme
|
||||||
@ -66,19 +45,38 @@ class AlbumInfoListTile extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
buildIcon() {
|
||||||
|
if (isSelected) {
|
||||||
|
return const Icon(
|
||||||
|
Icons.check_circle_rounded,
|
||||||
|
color: Colors.green,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isExcluded) {
|
||||||
|
return const Icon(
|
||||||
|
Icons.remove_circle_rounded,
|
||||||
|
color: Colors.red,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Icon(
|
||||||
|
Icons.circle,
|
||||||
|
color: context.isDarkTheme ? Colors.grey[400] : Colors.black45,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onDoubleTap: () {
|
onDoubleTap: () {
|
||||||
HapticFeedback.selectionClick();
|
HapticFeedback.selectionClick();
|
||||||
|
|
||||||
if (isExcluded) {
|
if (isExcluded) {
|
||||||
// Remove from exclude album list
|
// Remove from exclude album list
|
||||||
ref
|
ref.read(backupProvider.notifier).removeExcludedAlbumForBackup(album);
|
||||||
.read(backupProvider.notifier)
|
|
||||||
.removeExcludedAlbumForBackup(albumInfo);
|
|
||||||
} else {
|
} else {
|
||||||
// Add to exclude album list
|
// Add to exclude album list
|
||||||
|
|
||||||
if (albumInfo.id == 'isAll' || albumInfo.name == 'Recents') {
|
if (album.id == 'isAll' || album.name == 'Recents') {
|
||||||
ImmichToast.show(
|
ImmichToast.show(
|
||||||
context: context,
|
context: context,
|
||||||
msg: 'Cannot exclude album contains all assets',
|
msg: 'Cannot exclude album contains all assets',
|
||||||
@ -88,9 +86,7 @@ class AlbumInfoListTile extends HookConsumerWidget {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ref
|
ref.read(backupProvider.notifier).addExcludedAlbumForBackup(album);
|
||||||
.read(backupProvider.notifier)
|
|
||||||
.addExcludedAlbumForBackup(albumInfo);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
@ -99,33 +95,14 @@ class AlbumInfoListTile extends HookConsumerWidget {
|
|||||||
onTap: () {
|
onTap: () {
|
||||||
HapticFeedback.selectionClick();
|
HapticFeedback.selectionClick();
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
ref.read(backupProvider.notifier).removeAlbumForBackup(albumInfo);
|
ref.read(backupProvider.notifier).removeAlbumForBackup(album);
|
||||||
} else {
|
} else {
|
||||||
ref.read(backupProvider.notifier).addAlbumForBackup(albumInfo);
|
ref.read(backupProvider.notifier).addAlbumForBackup(album);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
leading: ClipRRect(
|
leading: buildIcon(),
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
child: SizedBox(
|
|
||||||
height: 80,
|
|
||||||
width: 80,
|
|
||||||
child: ColorFiltered(
|
|
||||||
colorFilter: buildImageFilter(),
|
|
||||||
child: Image(
|
|
||||||
width: double.infinity,
|
|
||||||
height: double.infinity,
|
|
||||||
image: imageData != null
|
|
||||||
? MemoryImage(imageData!)
|
|
||||||
: const AssetImage(
|
|
||||||
'assets/immich-logo.png',
|
|
||||||
) as ImageProvider,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
title: Text(
|
title: Text(
|
||||||
albumInfo.name,
|
album.name,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@ -135,7 +112,7 @@ class AlbumInfoListTile extends HookConsumerWidget {
|
|||||||
trailing: IconButton(
|
trailing: IconButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
context.pushRoute(
|
context.pushRoute(
|
||||||
AlbumPreviewRoute(album: albumInfo.albumEntity),
|
AlbumPreviewRoute(album: album.albumEntity),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
|
@ -43,10 +43,8 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
|||||||
sliver: SliverList(
|
sliver: SliverList(
|
||||||
delegate: SliverChildBuilderDelegate(
|
delegate: SliverChildBuilderDelegate(
|
||||||
((context, index) {
|
((context, index) {
|
||||||
var thumbnailData = albums[index].thumbnailData;
|
|
||||||
return AlbumInfoListTile(
|
return AlbumInfoListTile(
|
||||||
imageData: thumbnailData,
|
album: albums[index],
|
||||||
albumInfo: albums[index],
|
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
childCount: albums.length,
|
childCount: albums.length,
|
||||||
@ -74,10 +72,8 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
itemCount: albums.length,
|
itemCount: albums.length,
|
||||||
itemBuilder: ((context, index) {
|
itemBuilder: ((context, index) {
|
||||||
var thumbnailData = albums[index].thumbnailData;
|
|
||||||
return AlbumInfoCard(
|
return AlbumInfoCard(
|
||||||
imageData: thumbnailData,
|
album: albums[index],
|
||||||
albumInfo: albums[index],
|
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
@ -26,7 +26,7 @@ class BackupControllerPage extends HookConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
BackUpState backupState = ref.watch(backupProvider);
|
BackUpState backupState = ref.watch(backupProvider);
|
||||||
final hasAnyAlbum = backupState.selectedBackupAlbums.isNotEmpty;
|
final hasAnyAlbum = backupState.selectedBackupAlbums.isNotEmpty;
|
||||||
|
final didGetBackupInfo = useState(false);
|
||||||
bool hasExclusiveAccess =
|
bool hasExclusiveAccess =
|
||||||
backupState.backupProgress != BackUpProgressEnum.inBackground;
|
backupState.backupProgress != BackUpProgressEnum.inBackground;
|
||||||
bool shouldBackup = backupState.allUniqueAssets.length -
|
bool shouldBackup = backupState.allUniqueAssets.length -
|
||||||
@ -38,11 +38,6 @@ class BackupControllerPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() {
|
() {
|
||||||
if (backupState.backupProgress != BackUpProgressEnum.inProgress &&
|
|
||||||
backupState.backupProgress != BackUpProgressEnum.manualInProgress) {
|
|
||||||
ref.watch(backupProvider.notifier).getBackupInfo();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the background settings information just to make sure we
|
// Update the background settings information just to make sure we
|
||||||
// have the latest, since the platform channel will not update
|
// have the latest, since the platform channel will not update
|
||||||
// automatically
|
// automatically
|
||||||
@ -58,6 +53,18 @@ class BackupControllerPage extends HookConsumerWidget {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() {
|
||||||
|
if (backupState.backupProgress == BackUpProgressEnum.idle &&
|
||||||
|
!didGetBackupInfo.value) {
|
||||||
|
ref.watch(backupProvider.notifier).getBackupInfo();
|
||||||
|
didGetBackupInfo.value = true;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
[backupState.backupProgress],
|
||||||
|
);
|
||||||
|
|
||||||
Widget buildSelectedAlbumName() {
|
Widget buildSelectedAlbumName() {
|
||||||
var text = "backup_controller_page_backup_selected".tr();
|
var text = "backup_controller_page_backup_selected".tr();
|
||||||
var albums = ref.watch(backupProvider).selectedBackupAlbums;
|
var albums = ref.watch(backupProvider).selectedBackupAlbums;
|
||||||
@ -235,6 +242,15 @@ class BackupControllerPage extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
buildLoadingIndicator() {
|
||||||
|
return const Padding(
|
||||||
|
padding: EdgeInsets.only(top: 42.0),
|
||||||
|
child: Center(
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
@ -297,7 +313,10 @@ class BackupControllerPage extends HookConsumerWidget {
|
|||||||
if (!hasExclusiveAccess) buildBackgroundBackupInfo(),
|
if (!hasExclusiveAccess) buildBackgroundBackupInfo(),
|
||||||
buildBackupButton(),
|
buildBackupButton(),
|
||||||
]
|
]
|
||||||
: [buildFolderSelectionTile()],
|
: [
|
||||||
|
buildFolderSelectionTile(),
|
||||||
|
if (!didGetBackupInfo.value) buildLoadingIndicator(),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
223
mobile/lib/modules/home/ui/asset_grid/asset_drag_region.dart
Normal file
223
mobile/lib/modules/home/ui/asset_grid/asset_drag_region.dart
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
// ignore_for_file: library_private_types_in_public_api
|
||||||
|
// Based on https://stackoverflow.com/a/52625182
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:flutter/gestures.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
|
|
||||||
|
class AssetDragRegion extends StatefulWidget {
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
final void Function(AssetIndex valueKey)? onStart;
|
||||||
|
final void Function(AssetIndex valueKey)? onAssetEnter;
|
||||||
|
final void Function()? onEnd;
|
||||||
|
final void Function()? onScrollStart;
|
||||||
|
final void Function(ScrollDirection direction)? onScroll;
|
||||||
|
|
||||||
|
const AssetDragRegion({
|
||||||
|
super.key,
|
||||||
|
required this.child,
|
||||||
|
this.onStart,
|
||||||
|
this.onAssetEnter,
|
||||||
|
this.onEnd,
|
||||||
|
this.onScrollStart,
|
||||||
|
this.onScroll,
|
||||||
|
});
|
||||||
|
@override
|
||||||
|
State createState() => _AssetDragRegionState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AssetDragRegionState extends State<AssetDragRegion> {
|
||||||
|
late AssetIndex? assetUnderPointer;
|
||||||
|
late AssetIndex? anchorAsset;
|
||||||
|
|
||||||
|
// Scroll related state
|
||||||
|
static const double scrollOffset = 0.10;
|
||||||
|
double? topScrollOffset;
|
||||||
|
double? bottomScrollOffset;
|
||||||
|
Timer? scrollTimer;
|
||||||
|
late bool scrollNotified;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
assetUnderPointer = null;
|
||||||
|
anchorAsset = null;
|
||||||
|
scrollNotified = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
topScrollOffset = null;
|
||||||
|
bottomScrollOffset = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
scrollTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return RawGestureDetector(
|
||||||
|
gestures: {
|
||||||
|
_CustomLongPressGestureRecognizer: GestureRecognizerFactoryWithHandlers<
|
||||||
|
_CustomLongPressGestureRecognizer>(
|
||||||
|
() => _CustomLongPressGestureRecognizer(),
|
||||||
|
_registerCallbacks,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
child: widget.child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _registerCallbacks(_CustomLongPressGestureRecognizer recognizer) {
|
||||||
|
recognizer.onLongPressMoveUpdate = (details) => _onLongPressMove(details);
|
||||||
|
recognizer.onLongPressStart = (details) => _onLongPressStart(details);
|
||||||
|
recognizer.onLongPressUp = _onLongPressEnd;
|
||||||
|
recognizer.onLongPressCancel = _onLongPressEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
AssetIndex? _getValueKeyAtPositon(Offset position) {
|
||||||
|
final box = context.findAncestorRenderObjectOfType<RenderBox>();
|
||||||
|
if (box == null) return null;
|
||||||
|
|
||||||
|
final hitTestResult = BoxHitTestResult();
|
||||||
|
final local = box.globalToLocal(position);
|
||||||
|
if (!box.hitTest(hitTestResult, position: local)) return null;
|
||||||
|
|
||||||
|
return (hitTestResult.path
|
||||||
|
.firstWhereOrNull((hit) => hit.target is _AssetIndexProxy)
|
||||||
|
?.target as _AssetIndexProxy?)
|
||||||
|
?.index;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onLongPressStart(LongPressStartDetails event) {
|
||||||
|
/// Calculate widget height and scroll offset when long press starting instead of in [initState]
|
||||||
|
/// or [didChangeDependencies] as the grid might still be rendering into view to get the actual size
|
||||||
|
final height = context.size?.height;
|
||||||
|
if (height != null &&
|
||||||
|
(topScrollOffset == null || bottomScrollOffset == null)) {
|
||||||
|
topScrollOffset = height * scrollOffset;
|
||||||
|
bottomScrollOffset = height - topScrollOffset!;
|
||||||
|
}
|
||||||
|
|
||||||
|
final initialHit = _getValueKeyAtPositon(event.globalPosition);
|
||||||
|
anchorAsset = initialHit;
|
||||||
|
if (initialHit == null) return;
|
||||||
|
|
||||||
|
if (anchorAsset != null) {
|
||||||
|
widget.onStart?.call(anchorAsset!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onLongPressEnd() {
|
||||||
|
scrollNotified = false;
|
||||||
|
scrollTimer?.cancel();
|
||||||
|
widget.onEnd?.call();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onLongPressMove(LongPressMoveUpdateDetails event) {
|
||||||
|
if (anchorAsset == null) return;
|
||||||
|
if (topScrollOffset == null || bottomScrollOffset == null) return;
|
||||||
|
|
||||||
|
final currentDy = event.localPosition.dy;
|
||||||
|
|
||||||
|
if (currentDy > bottomScrollOffset!) {
|
||||||
|
scrollTimer ??= Timer.periodic(
|
||||||
|
const Duration(milliseconds: 50),
|
||||||
|
(_) => widget.onScroll?.call(ScrollDirection.forward),
|
||||||
|
);
|
||||||
|
} else if (currentDy < topScrollOffset!) {
|
||||||
|
scrollTimer ??= Timer.periodic(
|
||||||
|
const Duration(milliseconds: 50),
|
||||||
|
(_) => widget.onScroll?.call(ScrollDirection.reverse),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
scrollTimer?.cancel();
|
||||||
|
scrollTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final currentlyTouchingAsset = _getValueKeyAtPositon(event.globalPosition);
|
||||||
|
if (currentlyTouchingAsset == null) return;
|
||||||
|
|
||||||
|
if (assetUnderPointer != currentlyTouchingAsset) {
|
||||||
|
if (!scrollNotified) {
|
||||||
|
scrollNotified = true;
|
||||||
|
widget.onScrollStart?.call();
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.onAssetEnter?.call(currentlyTouchingAsset);
|
||||||
|
assetUnderPointer = currentlyTouchingAsset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CustomLongPressGestureRecognizer extends LongPressGestureRecognizer {
|
||||||
|
@override
|
||||||
|
void rejectGesture(int pointer) {
|
||||||
|
acceptGesture(pointer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ignore: prefer-single-widget-per-file
|
||||||
|
class AssetIndexWrapper extends SingleChildRenderObjectWidget {
|
||||||
|
final int rowIndex;
|
||||||
|
final int sectionIndex;
|
||||||
|
|
||||||
|
const AssetIndexWrapper({
|
||||||
|
required Widget super.child,
|
||||||
|
required this.rowIndex,
|
||||||
|
required this.sectionIndex,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
_AssetIndexProxy createRenderObject(BuildContext context) {
|
||||||
|
return _AssetIndexProxy(
|
||||||
|
index: AssetIndex(rowIndex: rowIndex, sectionIndex: sectionIndex),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void updateRenderObject(
|
||||||
|
BuildContext context,
|
||||||
|
_AssetIndexProxy renderObject,
|
||||||
|
) {
|
||||||
|
renderObject.index =
|
||||||
|
AssetIndex(rowIndex: rowIndex, sectionIndex: sectionIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AssetIndexProxy extends RenderProxyBox {
|
||||||
|
AssetIndex index;
|
||||||
|
|
||||||
|
_AssetIndexProxy({
|
||||||
|
required this.index,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class AssetIndex {
|
||||||
|
final int rowIndex;
|
||||||
|
final int sectionIndex;
|
||||||
|
|
||||||
|
const AssetIndex({
|
||||||
|
required this.rowIndex,
|
||||||
|
required this.sectionIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(covariant AssetIndex other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
|
||||||
|
return other.rowIndex == rowIndex && other.sectionIndex == sectionIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => rowIndex.hashCode ^ sectionIndex.hashCode;
|
||||||
|
}
|
@ -5,12 +5,15 @@ import 'dart:math';
|
|||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/collection_extensions.dart';
|
import 'package:immich_mobile/extensions/collection_extensions.dart';
|
||||||
import 'package:immich_mobile/modules/asset_viewer/providers/scroll_notifier.provider.dart';
|
import 'package:immich_mobile/modules/asset_viewer/providers/scroll_notifier.provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_drag_region.dart';
|
||||||
import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_image.dart';
|
import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_image.dart';
|
||||||
import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_placeholder.dart';
|
import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_placeholder.dart';
|
||||||
|
import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart';
|
||||||
import 'package:immich_mobile/shared/models/asset.dart';
|
import 'package:immich_mobile/shared/models/asset.dart';
|
||||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||||
|
|
||||||
@ -73,6 +76,8 @@ class ImmichAssetGridView extends StatefulWidget {
|
|||||||
|
|
||||||
class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
||||||
final ItemScrollController _itemScrollController = ItemScrollController();
|
final ItemScrollController _itemScrollController = ItemScrollController();
|
||||||
|
final ScrollOffsetController _scrollOffsetController =
|
||||||
|
ScrollOffsetController();
|
||||||
final ItemPositionsListener _itemPositionsListener =
|
final ItemPositionsListener _itemPositionsListener =
|
||||||
ItemPositionsListener.create();
|
ItemPositionsListener.create();
|
||||||
|
|
||||||
@ -83,6 +88,12 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
|||||||
final Set<Asset> _selectedAssets =
|
final Set<Asset> _selectedAssets =
|
||||||
LinkedHashSet(equals: (a, b) => a.id == b.id, hashCode: (a) => a.id);
|
LinkedHashSet(equals: (a, b) => a.id == b.id, hashCode: (a) => a.id);
|
||||||
|
|
||||||
|
bool _dragging = false;
|
||||||
|
int? _dragAnchorAssetIndex;
|
||||||
|
int? _dragAnchorSectionIndex;
|
||||||
|
final Set<Asset> _draggedAssets =
|
||||||
|
HashSet(equals: (a, b) => a.id == b.id, hashCode: (a) => a.id);
|
||||||
|
|
||||||
Set<Asset> _getSelectedAssets() {
|
Set<Asset> _getSelectedAssets() {
|
||||||
return Set.from(_selectedAssets);
|
return Set.from(_selectedAssets);
|
||||||
}
|
}
|
||||||
@ -93,20 +104,26 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
|||||||
|
|
||||||
void _selectAssets(List<Asset> assets) {
|
void _selectAssets(List<Asset> assets) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
if (_dragging) {
|
||||||
|
_draggedAssets.addAll(assets);
|
||||||
|
}
|
||||||
_selectedAssets.addAll(assets);
|
_selectedAssets.addAll(assets);
|
||||||
_callSelectionListener(true);
|
_callSelectionListener(true);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _deselectAssets(List<Asset> assets) {
|
void _deselectAssets(List<Asset> assets) {
|
||||||
|
final assetsToDeselect = assets.where(
|
||||||
|
(a) =>
|
||||||
|
widget.canDeselect ||
|
||||||
|
!(widget.preselectedAssets?.contains(a) ?? false),
|
||||||
|
);
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedAssets.removeAll(
|
_selectedAssets.removeAll(assetsToDeselect);
|
||||||
assets.where(
|
if (_dragging) {
|
||||||
(a) =>
|
_draggedAssets.removeAll(assetsToDeselect);
|
||||||
widget.canDeselect ||
|
}
|
||||||
!(widget.preselectedAssets?.contains(a) ?? false),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
_callSelectionListener(_selectedAssets.isNotEmpty);
|
_callSelectionListener(_selectedAssets.isNotEmpty);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -114,6 +131,10 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
|||||||
void _deselectAll() {
|
void _deselectAll() {
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedAssets.clear();
|
_selectedAssets.clear();
|
||||||
|
_dragAnchorAssetIndex = null;
|
||||||
|
_dragAnchorSectionIndex = null;
|
||||||
|
_draggedAssets.clear();
|
||||||
|
_dragging = false;
|
||||||
if (!widget.canDeselect &&
|
if (!widget.canDeselect &&
|
||||||
widget.preselectedAssets != null &&
|
widget.preselectedAssets != null &&
|
||||||
widget.preselectedAssets!.isNotEmpty) {
|
widget.preselectedAssets!.isNotEmpty) {
|
||||||
@ -142,6 +163,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
|||||||
showStorageIndicator: widget.showStorageIndicator,
|
showStorageIndicator: widget.showStorageIndicator,
|
||||||
selectedAssets: _selectedAssets,
|
selectedAssets: _selectedAssets,
|
||||||
selectionActive: widget.selectionActive,
|
selectionActive: widget.selectionActive,
|
||||||
|
sectionIndex: index,
|
||||||
section: section,
|
section: section,
|
||||||
margin: widget.margin,
|
margin: widget.margin,
|
||||||
renderList: widget.renderList,
|
renderList: widget.renderList,
|
||||||
@ -199,6 +221,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
|||||||
itemBuilder: _itemBuilder,
|
itemBuilder: _itemBuilder,
|
||||||
itemPositionsListener: _itemPositionsListener,
|
itemPositionsListener: _itemPositionsListener,
|
||||||
itemScrollController: _itemScrollController,
|
itemScrollController: _itemScrollController,
|
||||||
|
scrollOffsetController: _scrollOffsetController,
|
||||||
itemCount: widget.renderList.elements.length +
|
itemCount: widget.renderList.elements.length +
|
||||||
(widget.topWidget != null ? 1 : 0),
|
(widget.topWidget != null ? 1 : 0),
|
||||||
addRepaintBoundaries: true,
|
addRepaintBoundaries: true,
|
||||||
@ -253,6 +276,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
|||||||
if (widget.visibleItemsListener != null) {
|
if (widget.visibleItemsListener != null) {
|
||||||
_itemPositionsListener.itemPositions.removeListener(_positionListener);
|
_itemPositionsListener.itemPositions.removeListener(_positionListener);
|
||||||
}
|
}
|
||||||
|
_itemPositionsListener.itemPositions.removeListener(_hapticsListener);
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -308,6 +332,107 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _setDragStartIndex(AssetIndex index) {
|
||||||
|
setState(() {
|
||||||
|
_dragAnchorAssetIndex = index.rowIndex;
|
||||||
|
_dragAnchorSectionIndex = index.sectionIndex;
|
||||||
|
_dragging = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _stopDrag() {
|
||||||
|
setState(() {
|
||||||
|
_dragging = false;
|
||||||
|
_draggedAssets.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _dragDragScroll(ScrollDirection direction) {
|
||||||
|
_scrollOffsetController.animateScroll(
|
||||||
|
offset: direction == ScrollDirection.forward ? 175 : -175,
|
||||||
|
duration: const Duration(milliseconds: 125),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleDragAssetEnter(AssetIndex index) {
|
||||||
|
if (_dragAnchorSectionIndex == null || _dragAnchorAssetIndex == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final dragAnchorSectionIndex = _dragAnchorSectionIndex!;
|
||||||
|
final dragAnchorAssetIndex = _dragAnchorAssetIndex!;
|
||||||
|
|
||||||
|
late final int startSectionIndex;
|
||||||
|
late final int startSectionAssetIndex;
|
||||||
|
late final int endSectionIndex;
|
||||||
|
late final int endSectionAssetIndex;
|
||||||
|
|
||||||
|
if (index.sectionIndex < dragAnchorSectionIndex) {
|
||||||
|
startSectionIndex = index.sectionIndex;
|
||||||
|
startSectionAssetIndex = index.rowIndex;
|
||||||
|
endSectionIndex = dragAnchorSectionIndex;
|
||||||
|
endSectionAssetIndex = dragAnchorAssetIndex;
|
||||||
|
} else if (index.sectionIndex > dragAnchorSectionIndex) {
|
||||||
|
startSectionIndex = dragAnchorSectionIndex;
|
||||||
|
startSectionAssetIndex = dragAnchorAssetIndex;
|
||||||
|
endSectionIndex = index.sectionIndex;
|
||||||
|
endSectionAssetIndex = index.rowIndex;
|
||||||
|
} else {
|
||||||
|
startSectionIndex = dragAnchorSectionIndex;
|
||||||
|
endSectionIndex = dragAnchorSectionIndex;
|
||||||
|
|
||||||
|
// If same section, assign proper start / end asset Index
|
||||||
|
if (dragAnchorAssetIndex < index.rowIndex) {
|
||||||
|
startSectionAssetIndex = dragAnchorAssetIndex;
|
||||||
|
endSectionAssetIndex = index.rowIndex;
|
||||||
|
} else {
|
||||||
|
startSectionAssetIndex = index.rowIndex;
|
||||||
|
endSectionAssetIndex = dragAnchorAssetIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final selectedAssets = <Asset>{};
|
||||||
|
var currentSectionIndex = startSectionIndex;
|
||||||
|
while (currentSectionIndex < endSectionIndex) {
|
||||||
|
final section =
|
||||||
|
widget.renderList.elements.elementAtOrNull(currentSectionIndex);
|
||||||
|
if (section == null) continue;
|
||||||
|
|
||||||
|
final sectionAssets =
|
||||||
|
widget.renderList.loadAssets(section.offset, section.count);
|
||||||
|
|
||||||
|
if (currentSectionIndex == startSectionIndex) {
|
||||||
|
selectedAssets.addAll(
|
||||||
|
sectionAssets.slice(startSectionAssetIndex, sectionAssets.length),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
selectedAssets.addAll(sectionAssets);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentSectionIndex += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
final section = widget.renderList.elements.elementAtOrNull(endSectionIndex);
|
||||||
|
if (section != null) {
|
||||||
|
final sectionAssets =
|
||||||
|
widget.renderList.loadAssets(section.offset, section.count);
|
||||||
|
if (startSectionIndex == endSectionIndex) {
|
||||||
|
selectedAssets.addAll(
|
||||||
|
sectionAssets.slice(startSectionAssetIndex, endSectionAssetIndex + 1),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
selectedAssets.addAll(
|
||||||
|
sectionAssets.slice(0, endSectionAssetIndex + 1),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_deselectAssets(_draggedAssets.toList());
|
||||||
|
_draggedAssets.clear();
|
||||||
|
_draggedAssets.addAll(selectedAssets);
|
||||||
|
_selectAssets(_draggedAssets.toList());
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return PopScope(
|
return PopScope(
|
||||||
@ -315,7 +440,16 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
|||||||
onPopInvoked: (didPop) => !didPop ? _deselectAll() : null,
|
onPopInvoked: (didPop) => !didPop ? _deselectAll() : null,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
_buildAssetGrid(),
|
AssetDragRegion(
|
||||||
|
onStart: _setDragStartIndex,
|
||||||
|
onAssetEnter: _handleDragAssetEnter,
|
||||||
|
onEnd: _stopDrag,
|
||||||
|
onScroll: _dragDragScroll,
|
||||||
|
onScrollStart: () => WidgetsBinding.instance.addPostFrameCallback(
|
||||||
|
(_) => controlBottomAppBarNotifier.minimize(),
|
||||||
|
),
|
||||||
|
child: _buildAssetGrid(),
|
||||||
|
),
|
||||||
if (widget.showMultiSelectIndicator && widget.selectionActive)
|
if (widget.showMultiSelectIndicator && widget.selectionActive)
|
||||||
_buildMultiSelectIndicator(),
|
_buildMultiSelectIndicator(),
|
||||||
],
|
],
|
||||||
@ -361,6 +495,7 @@ class _PlaceholderRow extends StatelessWidget {
|
|||||||
/// A section for the render grid
|
/// A section for the render grid
|
||||||
class _Section extends StatelessWidget {
|
class _Section extends StatelessWidget {
|
||||||
final RenderAssetGridElement section;
|
final RenderAssetGridElement section;
|
||||||
|
final int sectionIndex;
|
||||||
final Set<Asset> selectedAssets;
|
final Set<Asset> selectedAssets;
|
||||||
final bool scrolling;
|
final bool scrolling;
|
||||||
final double margin;
|
final double margin;
|
||||||
@ -377,6 +512,7 @@ class _Section extends StatelessWidget {
|
|||||||
|
|
||||||
const _Section({
|
const _Section({
|
||||||
required this.section,
|
required this.section,
|
||||||
|
required this.sectionIndex,
|
||||||
required this.scrolling,
|
required this.scrolling,
|
||||||
required this.margin,
|
required this.margin,
|
||||||
required this.assetsPerRow,
|
required this.assetsPerRow,
|
||||||
@ -435,6 +571,8 @@ class _Section extends StatelessWidget {
|
|||||||
)
|
)
|
||||||
: _AssetRow(
|
: _AssetRow(
|
||||||
key: ValueKey(i),
|
key: ValueKey(i),
|
||||||
|
rowStartIndex: i * assetsPerRow,
|
||||||
|
sectionIndex: sectionIndex,
|
||||||
assets: assetsToRender.nestedSlice(
|
assets: assetsToRender.nestedSlice(
|
||||||
i * assetsPerRow,
|
i * assetsPerRow,
|
||||||
min((i + 1) * assetsPerRow, section.count),
|
min((i + 1) * assetsPerRow, section.count),
|
||||||
@ -522,6 +660,8 @@ class _Title extends StatelessWidget {
|
|||||||
/// The row of assets
|
/// The row of assets
|
||||||
class _AssetRow extends StatelessWidget {
|
class _AssetRow extends StatelessWidget {
|
||||||
final List<Asset> assets;
|
final List<Asset> assets;
|
||||||
|
final int rowStartIndex;
|
||||||
|
final int sectionIndex;
|
||||||
final Set<Asset> selectedAssets;
|
final Set<Asset> selectedAssets;
|
||||||
final int absoluteOffset;
|
final int absoluteOffset;
|
||||||
final double width;
|
final double width;
|
||||||
@ -539,6 +679,8 @@ class _AssetRow extends StatelessWidget {
|
|||||||
|
|
||||||
const _AssetRow({
|
const _AssetRow({
|
||||||
super.key,
|
super.key,
|
||||||
|
required this.rowStartIndex,
|
||||||
|
required this.sectionIndex,
|
||||||
required this.assets,
|
required this.assets,
|
||||||
required this.absoluteOffset,
|
required this.absoluteOffset,
|
||||||
required this.width,
|
required this.width,
|
||||||
@ -594,18 +736,22 @@ class _AssetRow extends StatelessWidget {
|
|||||||
bottom: margin,
|
bottom: margin,
|
||||||
right: last ? 0.0 : margin,
|
right: last ? 0.0 : margin,
|
||||||
),
|
),
|
||||||
child: ThumbnailImage(
|
child: AssetIndexWrapper(
|
||||||
asset: asset,
|
rowIndex: rowStartIndex + index,
|
||||||
index: absoluteOffset + index,
|
sectionIndex: sectionIndex,
|
||||||
loadAsset: renderList.loadAsset,
|
child: ThumbnailImage(
|
||||||
totalAssets: renderList.totalAssets,
|
asset: asset,
|
||||||
multiselectEnabled: selectionActive,
|
index: absoluteOffset + index,
|
||||||
isSelected: isSelectionActive && selectedAssets.contains(asset),
|
loadAsset: renderList.loadAsset,
|
||||||
onSelect: () => onSelect?.call(asset),
|
totalAssets: renderList.totalAssets,
|
||||||
onDeselect: () => onDeselect?.call(asset),
|
multiselectEnabled: selectionActive,
|
||||||
showStorageIndicator: showStorageIndicator,
|
isSelected: isSelectionActive && selectedAssets.contains(asset),
|
||||||
heroOffset: heroOffset,
|
onSelect: () => onSelect?.call(asset),
|
||||||
showStack: showStack,
|
onDeselect: () => onDeselect?.call(asset),
|
||||||
|
showStorageIndicator: showStorageIndicator,
|
||||||
|
heroOffset: heroOffset,
|
||||||
|
showStack: showStack,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
||||||
@ -11,8 +12,17 @@ import 'package:immich_mobile/modules/home/ui/upload_dialog.dart';
|
|||||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||||
import 'package:immich_mobile/shared/ui/drag_sheet.dart';
|
import 'package:immich_mobile/shared/ui/drag_sheet.dart';
|
||||||
import 'package:immich_mobile/shared/models/album.dart';
|
import 'package:immich_mobile/shared/models/album.dart';
|
||||||
|
import 'package:immich_mobile/utils/draggable_scroll_controller.dart';
|
||||||
|
|
||||||
class ControlBottomAppBar extends ConsumerWidget {
|
final controlBottomAppBarNotifier = ControlBottomAppBarNotifier();
|
||||||
|
|
||||||
|
class ControlBottomAppBarNotifier with ChangeNotifier {
|
||||||
|
void minimize() {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ControlBottomAppBar extends HookConsumerWidget {
|
||||||
final void Function(bool shareLocal) onShare;
|
final void Function(bool shareLocal) onShare;
|
||||||
final void Function()? onFavorite;
|
final void Function()? onFavorite;
|
||||||
final void Function()? onArchive;
|
final void Function()? onArchive;
|
||||||
@ -64,6 +74,25 @@ class ControlBottomAppBar extends ConsumerWidget {
|
|||||||
final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList();
|
final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList();
|
||||||
final sharedAlbums = ref.watch(sharedAlbumProvider);
|
final sharedAlbums = ref.watch(sharedAlbumProvider);
|
||||||
const bottomPadding = 0.20;
|
const bottomPadding = 0.20;
|
||||||
|
final scrollController = useDraggableScrollController();
|
||||||
|
|
||||||
|
void minimize() {
|
||||||
|
scrollController.animateTo(
|
||||||
|
bottomPadding,
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() {
|
||||||
|
controlBottomAppBarNotifier.addListener(minimize);
|
||||||
|
return () {
|
||||||
|
controlBottomAppBarNotifier.removeListener(minimize);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
void showForceDeleteDialog(
|
void showForceDeleteDialog(
|
||||||
Function(bool) deleteCb, {
|
Function(bool) deleteCb, {
|
||||||
@ -242,6 +271,7 @@ class ControlBottomAppBar extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return DraggableScrollableSheet(
|
return DraggableScrollableSheet(
|
||||||
|
controller: scrollController,
|
||||||
initialChildSize: hasRemote ? 0.35 : bottomPadding,
|
initialChildSize: hasRemote ? 0.35 : bottomPadding,
|
||||||
minChildSize: bottomPadding,
|
minChildSize: bottomPadding,
|
||||||
maxChildSize: hasRemote ? 0.65 : bottomPadding,
|
maxChildSize: hasRemote ? 0.65 : bottomPadding,
|
||||||
|
@ -23,7 +23,7 @@ class CuratedPeopleRow extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
const imageSize = 85.0;
|
const imageSize = 80.0;
|
||||||
|
|
||||||
// Guard empty [content]
|
// Guard empty [content]
|
||||||
if (content.isEmpty) {
|
if (content.isEmpty) {
|
||||||
|
13
mobile/lib/shared/providers/immich_logo_provider.dart
Normal file
13
mobile/lib/shared/providers/immich_logo_provider.dart
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
|
part 'immich_logo_provider.g.dart';
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
Future<Uint8List> immichLogo(ImmichLogoRef ref) async {
|
||||||
|
final json = await rootBundle.loadString('assets/immich-logo.json');
|
||||||
|
final j = jsonDecode(json);
|
||||||
|
return base64Decode(j['content']);
|
||||||
|
}
|
24
mobile/lib/shared/providers/immich_logo_provider.g.dart
generated
Normal file
24
mobile/lib/shared/providers/immich_logo_provider.g.dart
generated
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'immich_logo_provider.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// RiverpodGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
String _$immichLogoHash() => r'040cc44fae3339e0f40a091fb3b2f2abe9f83acd';
|
||||||
|
|
||||||
|
/// See also [immichLogo].
|
||||||
|
@ProviderFor(immichLogo)
|
||||||
|
final immichLogoProvider = AutoDisposeFutureProvider<Uint8List>.internal(
|
||||||
|
immichLogo,
|
||||||
|
name: r'immichLogoProvider',
|
||||||
|
debugGetCreateSourceHash:
|
||||||
|
const bool.fromEnvironment('dart.vm.product') ? null : _$immichLogoHash,
|
||||||
|
dependencies: null,
|
||||||
|
allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
typedef ImmichLogoRef = AutoDisposeFutureProviderRef<Uint8List>;
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
|
@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/shared/models/store.dart';
|
import 'package:immich_mobile/shared/models/store.dart';
|
||||||
|
import 'package:immich_mobile/shared/providers/immich_logo_provider.dart';
|
||||||
import 'package:immich_mobile/shared/ui/app_bar_dialog/app_bar_dialog.dart';
|
import 'package:immich_mobile/shared/ui/app_bar_dialog/app_bar_dialog.dart';
|
||||||
import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
|
import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
|
||||||
|
|
||||||
@ -26,6 +27,7 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
|||||||
final bool isEnableAutoBackup =
|
final bool isEnableAutoBackup =
|
||||||
backupState.backgroundBackup || backupState.autoBackup;
|
backupState.backgroundBackup || backupState.autoBackup;
|
||||||
final ServerInfo serverInfoState = ref.watch(serverInfoProvider);
|
final ServerInfo serverInfoState = ref.watch(serverInfoProvider);
|
||||||
|
final immichLogo = ref.watch(immichLogoProvider);
|
||||||
final user = Store.tryGet(StoreKey.currentUser);
|
final user = Store.tryGet(StoreKey.currentUser);
|
||||||
final isDarkTheme = context.isDarkTheme;
|
final isDarkTheme = context.isDarkTheme;
|
||||||
const widgetSize = 30.0;
|
const widgetSize = 30.0;
|
||||||
@ -152,14 +154,29 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
|||||||
builder: (BuildContext context) {
|
builder: (BuildContext context) {
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Builder(
|
||||||
padding: const EdgeInsets.only(top: 3),
|
builder: (context) {
|
||||||
height: 30,
|
final today = DateTime.now();
|
||||||
child: Image.asset(
|
if (today.month == 4 && today.day == 1) {
|
||||||
context.isDarkTheme
|
if (immichLogo.value == null) {
|
||||||
? 'assets/immich-logo-inline-dark.png'
|
return const SizedBox.shrink();
|
||||||
: 'assets/immich-logo-inline-light.png',
|
}
|
||||||
),
|
return Image.memory(
|
||||||
|
immichLogo.value!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
height: 80,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 3.0),
|
||||||
|
child: Image.asset(
|
||||||
|
height: 30,
|
||||||
|
context.isDarkTheme
|
||||||
|
? 'assets/immich-logo-inline-dark.png'
|
||||||
|
: 'assets/immich-logo-inline-light.png',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
2
mobile/openapi/doc/CreateLibraryDto.md
generated
2
mobile/openapi/doc/CreateLibraryDto.md
generated
@ -13,7 +13,7 @@ Name | Type | Description | Notes
|
|||||||
**isVisible** | **bool** | | [optional]
|
**isVisible** | **bool** | | [optional]
|
||||||
**isWatched** | **bool** | | [optional]
|
**isWatched** | **bool** | | [optional]
|
||||||
**name** | **String** | | [optional]
|
**name** | **String** | | [optional]
|
||||||
**ownerId** | **String** | | [optional]
|
**ownerId** | **String** | |
|
||||||
**type** | [**LibraryType**](LibraryType.md) | |
|
**type** | [**LibraryType**](LibraryType.md) | |
|
||||||
|
|
||||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||||
|
@ -9,7 +9,7 @@ import 'package:openapi/api.dart';
|
|||||||
Name | Type | Description | Notes
|
Name | Type | Description | Notes
|
||||||
------------ | ------------- | ------------- | -------------
|
------------ | ------------- | ------------- | -------------
|
||||||
**importPath** | **String** | |
|
**importPath** | **String** | |
|
||||||
**isValid** | **bool** | | [optional] [default to false]
|
**isValid** | **bool** | | [default to false]
|
||||||
**message** | **String** | | [optional]
|
**message** | **String** | | [optional]
|
||||||
|
|
||||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||||
|
19
mobile/openapi/lib/model/create_library_dto.dart
generated
19
mobile/openapi/lib/model/create_library_dto.dart
generated
@ -18,7 +18,7 @@ class CreateLibraryDto {
|
|||||||
this.isVisible,
|
this.isVisible,
|
||||||
this.isWatched,
|
this.isWatched,
|
||||||
this.name,
|
this.name,
|
||||||
this.ownerId,
|
required this.ownerId,
|
||||||
required this.type,
|
required this.type,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -50,13 +50,7 @@ class CreateLibraryDto {
|
|||||||
///
|
///
|
||||||
String? name;
|
String? name;
|
||||||
|
|
||||||
///
|
String ownerId;
|
||||||
/// Please note: This property should have been non-nullable! Since the specification file
|
|
||||||
/// does not include a default value (using the "default:" property), however, the generated
|
|
||||||
/// source code must fall back to having a nullable type.
|
|
||||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
|
||||||
///
|
|
||||||
String? ownerId;
|
|
||||||
|
|
||||||
LibraryType type;
|
LibraryType type;
|
||||||
|
|
||||||
@ -78,7 +72,7 @@ class CreateLibraryDto {
|
|||||||
(isVisible == null ? 0 : isVisible!.hashCode) +
|
(isVisible == null ? 0 : isVisible!.hashCode) +
|
||||||
(isWatched == null ? 0 : isWatched!.hashCode) +
|
(isWatched == null ? 0 : isWatched!.hashCode) +
|
||||||
(name == null ? 0 : name!.hashCode) +
|
(name == null ? 0 : name!.hashCode) +
|
||||||
(ownerId == null ? 0 : ownerId!.hashCode) +
|
(ownerId.hashCode) +
|
||||||
(type.hashCode);
|
(type.hashCode);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -103,11 +97,7 @@ class CreateLibraryDto {
|
|||||||
} else {
|
} else {
|
||||||
// json[r'name'] = null;
|
// json[r'name'] = null;
|
||||||
}
|
}
|
||||||
if (this.ownerId != null) {
|
|
||||||
json[r'ownerId'] = this.ownerId;
|
json[r'ownerId'] = this.ownerId;
|
||||||
} else {
|
|
||||||
// json[r'ownerId'] = null;
|
|
||||||
}
|
|
||||||
json[r'type'] = this.type;
|
json[r'type'] = this.type;
|
||||||
return json;
|
return json;
|
||||||
}
|
}
|
||||||
@ -129,7 +119,7 @@ class CreateLibraryDto {
|
|||||||
isVisible: mapValueOfType<bool>(json, r'isVisible'),
|
isVisible: mapValueOfType<bool>(json, r'isVisible'),
|
||||||
isWatched: mapValueOfType<bool>(json, r'isWatched'),
|
isWatched: mapValueOfType<bool>(json, r'isWatched'),
|
||||||
name: mapValueOfType<String>(json, r'name'),
|
name: mapValueOfType<String>(json, r'name'),
|
||||||
ownerId: mapValueOfType<String>(json, r'ownerId'),
|
ownerId: mapValueOfType<String>(json, r'ownerId')!,
|
||||||
type: LibraryType.fromJson(json[r'type'])!,
|
type: LibraryType.fromJson(json[r'type'])!,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -178,6 +168,7 @@ class CreateLibraryDto {
|
|||||||
|
|
||||||
/// 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>{
|
||||||
|
'ownerId',
|
||||||
'type',
|
'type',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -67,7 +67,7 @@ class ValidateLibraryImportPathResponseDto {
|
|||||||
|
|
||||||
return ValidateLibraryImportPathResponseDto(
|
return ValidateLibraryImportPathResponseDto(
|
||||||
importPath: mapValueOfType<String>(json, r'importPath')!,
|
importPath: mapValueOfType<String>(json, r'importPath')!,
|
||||||
isValid: mapValueOfType<bool>(json, r'isValid') ?? false,
|
isValid: mapValueOfType<bool>(json, r'isValid')!,
|
||||||
message: mapValueOfType<String>(json, r'message'),
|
message: mapValueOfType<String>(json, r'message'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -117,6 +117,7 @@ class ValidateLibraryImportPathResponseDto {
|
|||||||
/// 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>{
|
||||||
'importPath',
|
'importPath',
|
||||||
|
'isValid',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -7646,6 +7646,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
"ownerId",
|
||||||
"type"
|
"type"
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
@ -10689,7 +10690,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"importPath"
|
"importPath",
|
||||||
|
"isValid"
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
@ -466,7 +466,7 @@ export type CreateLibraryDto = {
|
|||||||
isVisible?: boolean;
|
isVisible?: boolean;
|
||||||
isWatched?: boolean;
|
isWatched?: boolean;
|
||||||
name?: string;
|
name?: string;
|
||||||
ownerId?: string;
|
ownerId: string;
|
||||||
"type": LibraryType;
|
"type": LibraryType;
|
||||||
};
|
};
|
||||||
export type UpdateLibraryDto = {
|
export type UpdateLibraryDto = {
|
||||||
@ -491,7 +491,7 @@ export type ValidateLibraryDto = {
|
|||||||
};
|
};
|
||||||
export type ValidateLibraryImportPathResponseDto = {
|
export type ValidateLibraryImportPathResponseDto = {
|
||||||
importPath: string;
|
importPath: string;
|
||||||
isValid?: boolean;
|
isValid: boolean;
|
||||||
message?: string;
|
message?: string;
|
||||||
};
|
};
|
||||||
export type ValidateLibraryResponseDto = {
|
export type ValidateLibraryResponseDto = {
|
||||||
|
@ -46,6 +46,7 @@ describe(`Library watcher (e2e)`, () => {
|
|||||||
describe('Single import path', () => {
|
describe('Single import path', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await api.libraryApi.create(server, admin.accessToken, {
|
await api.libraryApi.create(server, admin.accessToken, {
|
||||||
|
ownerId: admin.userId,
|
||||||
type: LibraryType.EXTERNAL,
|
type: LibraryType.EXTERNAL,
|
||||||
importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`],
|
importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`],
|
||||||
});
|
});
|
||||||
@ -133,6 +134,7 @@ describe(`Library watcher (e2e)`, () => {
|
|||||||
await fs.mkdir(`${IMMICH_TEST_ASSET_TEMP_PATH}/dir3`, { recursive: true });
|
await fs.mkdir(`${IMMICH_TEST_ASSET_TEMP_PATH}/dir3`, { recursive: true });
|
||||||
|
|
||||||
await api.libraryApi.create(server, admin.accessToken, {
|
await api.libraryApi.create(server, admin.accessToken, {
|
||||||
|
ownerId: admin.userId,
|
||||||
type: LibraryType.EXTERNAL,
|
type: LibraryType.EXTERNAL,
|
||||||
importPaths: [
|
importPaths: [
|
||||||
`${IMMICH_TEST_ASSET_TEMP_PATH}/dir1`,
|
`${IMMICH_TEST_ASSET_TEMP_PATH}/dir1`,
|
||||||
@ -190,6 +192,7 @@ describe(`Library watcher (e2e)`, () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
library = await api.libraryApi.create(server, admin.accessToken, {
|
library = await api.libraryApi.create(server, admin.accessToken, {
|
||||||
|
ownerId: admin.userId,
|
||||||
type: LibraryType.EXTERNAL,
|
type: LibraryType.EXTERNAL,
|
||||||
importPaths: [
|
importPaths: [
|
||||||
`${IMMICH_TEST_ASSET_TEMP_PATH}/dir1`,
|
`${IMMICH_TEST_ASSET_TEMP_PATH}/dir1`,
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { LibraryResponseDto, LoginResponseDto } from '@app/domain';
|
import { LoginResponseDto } from '@app/domain';
|
||||||
import { LibraryController } from '@app/immich';
|
import { LibraryController } from '@app/immich';
|
||||||
import { AssetType, LibraryType } from '@app/infra/entities';
|
import { LibraryType } from '@app/infra/entities';
|
||||||
import { errorStub, uuidStub } from '@test/fixtures';
|
import { errorStub, uuidStub } from '@test/fixtures';
|
||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
@ -41,6 +41,7 @@ describe(`${LibraryController.name} (e2e)`, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const library = await api.libraryApi.create(server, admin.accessToken, {
|
const library = await api.libraryApi.create(server, admin.accessToken, {
|
||||||
|
ownerId: admin.userId,
|
||||||
type: LibraryType.EXTERNAL,
|
type: LibraryType.EXTERNAL,
|
||||||
importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`],
|
importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`],
|
||||||
});
|
});
|
||||||
@ -72,6 +73,7 @@ describe(`${LibraryController.name} (e2e)`, () => {
|
|||||||
|
|
||||||
it('should scan new files', async () => {
|
it('should scan new files', async () => {
|
||||||
const library = await api.libraryApi.create(server, admin.accessToken, {
|
const library = await api.libraryApi.create(server, admin.accessToken, {
|
||||||
|
ownerId: admin.userId,
|
||||||
type: LibraryType.EXTERNAL,
|
type: LibraryType.EXTERNAL,
|
||||||
importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`],
|
importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`],
|
||||||
});
|
});
|
||||||
@ -107,6 +109,7 @@ describe(`${LibraryController.name} (e2e)`, () => {
|
|||||||
describe('with refreshModifiedFiles=true', () => {
|
describe('with refreshModifiedFiles=true', () => {
|
||||||
it('should reimport modified files', async () => {
|
it('should reimport modified files', async () => {
|
||||||
const library = await api.libraryApi.create(server, admin.accessToken, {
|
const library = await api.libraryApi.create(server, admin.accessToken, {
|
||||||
|
ownerId: admin.userId,
|
||||||
type: LibraryType.EXTERNAL,
|
type: LibraryType.EXTERNAL,
|
||||||
importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`],
|
importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`],
|
||||||
});
|
});
|
||||||
@ -153,6 +156,7 @@ describe(`${LibraryController.name} (e2e)`, () => {
|
|||||||
|
|
||||||
it('should not reimport unmodified files', async () => {
|
it('should not reimport unmodified files', async () => {
|
||||||
const library = await api.libraryApi.create(server, admin.accessToken, {
|
const library = await api.libraryApi.create(server, admin.accessToken, {
|
||||||
|
ownerId: admin.userId,
|
||||||
type: LibraryType.EXTERNAL,
|
type: LibraryType.EXTERNAL,
|
||||||
importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`],
|
importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`],
|
||||||
});
|
});
|
||||||
@ -192,6 +196,7 @@ describe(`${LibraryController.name} (e2e)`, () => {
|
|||||||
describe('with refreshAllFiles=true', () => {
|
describe('with refreshAllFiles=true', () => {
|
||||||
it('should reimport all files', async () => {
|
it('should reimport all files', async () => {
|
||||||
const library = await api.libraryApi.create(server, admin.accessToken, {
|
const library = await api.libraryApi.create(server, admin.accessToken, {
|
||||||
|
ownerId: admin.userId,
|
||||||
type: LibraryType.EXTERNAL,
|
type: LibraryType.EXTERNAL,
|
||||||
importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`],
|
importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`],
|
||||||
});
|
});
|
||||||
@ -251,6 +256,7 @@ describe(`${LibraryController.name} (e2e)`, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const library = await api.libraryApi.create(server, admin.accessToken, {
|
const library = await api.libraryApi.create(server, admin.accessToken, {
|
||||||
|
ownerId: admin.userId,
|
||||||
type: LibraryType.EXTERNAL,
|
type: LibraryType.EXTERNAL,
|
||||||
importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`],
|
importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`],
|
||||||
});
|
});
|
||||||
@ -277,6 +283,7 @@ describe(`${LibraryController.name} (e2e)`, () => {
|
|||||||
|
|
||||||
it('should not remove online files', async () => {
|
it('should not remove online files', async () => {
|
||||||
const library = await api.libraryApi.create(server, admin.accessToken, {
|
const library = await api.libraryApi.create(server, admin.accessToken, {
|
||||||
|
ownerId: admin.userId,
|
||||||
type: LibraryType.EXTERNAL,
|
type: LibraryType.EXTERNAL,
|
||||||
importPaths: [`${IMMICH_TEST_ASSET_PATH}/albums/nature`],
|
importPaths: [`${IMMICH_TEST_ASSET_PATH}/albums/nature`],
|
||||||
});
|
});
|
||||||
|
@ -153,7 +153,7 @@
|
|||||||
"coverageDirectory": "./coverage",
|
"coverageDirectory": "./coverage",
|
||||||
"coverageThreshold": {
|
"coverageThreshold": {
|
||||||
"./src/domain/": {
|
"./src/domain/": {
|
||||||
"branches": 79,
|
"branches": 75,
|
||||||
"functions": 80,
|
"functions": 80,
|
||||||
"lines": 90,
|
"lines": 90,
|
||||||
"statements": 90
|
"statements": 90
|
||||||
|
@ -33,12 +33,6 @@ export enum Permission {
|
|||||||
TIMELINE_READ = 'timeline.read',
|
TIMELINE_READ = 'timeline.read',
|
||||||
TIMELINE_DOWNLOAD = 'timeline.download',
|
TIMELINE_DOWNLOAD = 'timeline.download',
|
||||||
|
|
||||||
LIBRARY_CREATE = 'library.create',
|
|
||||||
LIBRARY_READ = 'library.read',
|
|
||||||
LIBRARY_UPDATE = 'library.update',
|
|
||||||
LIBRARY_DELETE = 'library.delete',
|
|
||||||
LIBRARY_DOWNLOAD = 'library.download',
|
|
||||||
|
|
||||||
PERSON_READ = 'person.read',
|
PERSON_READ = 'person.read',
|
||||||
PERSON_WRITE = 'person.write',
|
PERSON_WRITE = 'person.write',
|
||||||
PERSON_MERGE = 'person.merge',
|
PERSON_MERGE = 'person.merge',
|
||||||
@ -261,29 +255,6 @@ export class AccessCore {
|
|||||||
return ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set();
|
return ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set();
|
||||||
}
|
}
|
||||||
|
|
||||||
case Permission.LIBRARY_READ: {
|
|
||||||
if (auth.user.isAdmin) {
|
|
||||||
return new Set(ids);
|
|
||||||
}
|
|
||||||
const isOwner = await this.repository.library.checkOwnerAccess(auth.user.id, ids);
|
|
||||||
const isPartner = await this.repository.library.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner));
|
|
||||||
return setUnion(isOwner, isPartner);
|
|
||||||
}
|
|
||||||
|
|
||||||
case Permission.LIBRARY_UPDATE: {
|
|
||||||
if (auth.user.isAdmin) {
|
|
||||||
return new Set(ids);
|
|
||||||
}
|
|
||||||
return await this.repository.library.checkOwnerAccess(auth.user.id, ids);
|
|
||||||
}
|
|
||||||
|
|
||||||
case Permission.LIBRARY_DELETE: {
|
|
||||||
if (auth.user.isAdmin) {
|
|
||||||
return new Set(ids);
|
|
||||||
}
|
|
||||||
return await this.repository.library.checkOwnerAccess(auth.user.id, ids);
|
|
||||||
}
|
|
||||||
|
|
||||||
case Permission.PERSON_READ: {
|
case Permission.PERSON_READ: {
|
||||||
return await this.repository.person.checkOwnerAccess(auth.user.id, ids);
|
return await this.repository.person.checkOwnerAccess(auth.user.id, ids);
|
||||||
}
|
}
|
||||||
|
@ -15,6 +15,8 @@ import {
|
|||||||
newStorageRepositoryMock,
|
newStorageRepositoryMock,
|
||||||
newSystemConfigRepositoryMock,
|
newSystemConfigRepositoryMock,
|
||||||
newUserRepositoryMock,
|
newUserRepositoryMock,
|
||||||
|
partnerStub,
|
||||||
|
userStub,
|
||||||
} from '@test';
|
} from '@test';
|
||||||
import { when } from 'jest-when';
|
import { when } from 'jest-when';
|
||||||
import { JobName } from '../job';
|
import { JobName } from '../job';
|
||||||
@ -317,6 +319,7 @@ describe(AssetService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should set the title correctly', async () => {
|
it('should set the title correctly', async () => {
|
||||||
|
partnerMock.getAll.mockResolvedValue([]);
|
||||||
assetMock.getByDayOfYear.mockResolvedValue([assetStub.image, assetStub.imageFrom2015]);
|
assetMock.getByDayOfYear.mockResolvedValue([assetStub.image, assetStub.imageFrom2015]);
|
||||||
|
|
||||||
await expect(sut.getMemoryLane(authStub.admin, { day: 15, month: 1 })).resolves.toEqual([
|
await expect(sut.getMemoryLane(authStub.admin, { day: 15, month: 1 })).resolves.toEqual([
|
||||||
@ -324,7 +327,17 @@ describe(AssetService.name, () => {
|
|||||||
{ title: '9 years since...', assets: [mapAsset(assetStub.imageFrom2015)] },
|
{ title: '9 years since...', assets: [mapAsset(assetStub.imageFrom2015)] },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(assetMock.getByDayOfYear.mock.calls).toEqual([[authStub.admin.user.id, { day: 15, month: 1 }]]);
|
expect(assetMock.getByDayOfYear.mock.calls).toEqual([[[authStub.admin.user.id], { day: 15, month: 1 }]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get memories with partners with inTimeline enabled', async () => {
|
||||||
|
partnerMock.getAll.mockResolvedValue([partnerStub.user1ToAdmin1]);
|
||||||
|
|
||||||
|
await sut.getMemoryLane(authStub.admin, { day: 15, month: 1 });
|
||||||
|
|
||||||
|
expect(assetMock.getByDayOfYear.mock.calls).toEqual([
|
||||||
|
[[authStub.admin.user.id, userStub.user1.id], { day: 15, month: 1 }],
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -172,7 +172,16 @@ export class AssetService {
|
|||||||
|
|
||||||
async getMemoryLane(auth: AuthDto, dto: MemoryLaneDto): Promise<MemoryLaneResponseDto[]> {
|
async getMemoryLane(auth: AuthDto, dto: MemoryLaneDto): Promise<MemoryLaneResponseDto[]> {
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
const assets = await this.assetRepository.getByDayOfYear(auth.user.id, dto);
|
|
||||||
|
// get partners id
|
||||||
|
const userIds: string[] = [auth.user.id];
|
||||||
|
const partners = await this.partnerRepository.getAll(auth.user.id);
|
||||||
|
const partnersIds = partners
|
||||||
|
.filter((partner) => partner.sharedBy && partner.inTimeline)
|
||||||
|
.map((partner) => partner.sharedById);
|
||||||
|
userIds.push(...partnersIds);
|
||||||
|
|
||||||
|
const assets = await this.assetRepository.getByDayOfYear(userIds, dto);
|
||||||
|
|
||||||
return _.chain(assets)
|
return _.chain(assets)
|
||||||
.filter((asset) => asset.localDateTime.getFullYear() < currentYear)
|
.filter((asset) => asset.localDateTime.getFullYear() < currentYear)
|
||||||
|
@ -8,8 +8,8 @@ export class CreateLibraryDto {
|
|||||||
@ApiProperty({ enumName: 'LibraryType', enum: LibraryType })
|
@ApiProperty({ enumName: 'LibraryType', enum: LibraryType })
|
||||||
type!: LibraryType;
|
type!: LibraryType;
|
||||||
|
|
||||||
@ValidateUUID({ optional: true })
|
@ValidateUUID()
|
||||||
ownerId?: string;
|
ownerId!: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@Optional()
|
@Optional()
|
||||||
|
@ -706,7 +706,7 @@ describe(LibraryService.name, () => {
|
|||||||
libraryMock.getUploadLibraryCount.mockResolvedValue(2);
|
libraryMock.getUploadLibraryCount.mockResolvedValue(2);
|
||||||
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
|
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
|
||||||
|
|
||||||
await sut.delete(authStub.admin, libraryStub.externalLibrary1.id);
|
await sut.delete(libraryStub.externalLibrary1.id);
|
||||||
|
|
||||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||||
name: JobName.LIBRARY_DELETE,
|
name: JobName.LIBRARY_DELETE,
|
||||||
@ -721,9 +721,7 @@ describe(LibraryService.name, () => {
|
|||||||
libraryMock.getUploadLibraryCount.mockResolvedValue(1);
|
libraryMock.getUploadLibraryCount.mockResolvedValue(1);
|
||||||
libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1);
|
libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1);
|
||||||
|
|
||||||
await expect(sut.delete(authStub.admin, libraryStub.uploadLibrary1.id)).rejects.toBeInstanceOf(
|
await expect(sut.delete(libraryStub.uploadLibrary1.id)).rejects.toBeInstanceOf(BadRequestException);
|
||||||
BadRequestException,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(jobMock.queue).not.toHaveBeenCalled();
|
expect(jobMock.queue).not.toHaveBeenCalled();
|
||||||
expect(jobMock.queueAll).not.toHaveBeenCalled();
|
expect(jobMock.queueAll).not.toHaveBeenCalled();
|
||||||
@ -735,7 +733,7 @@ describe(LibraryService.name, () => {
|
|||||||
libraryMock.getUploadLibraryCount.mockResolvedValue(1);
|
libraryMock.getUploadLibraryCount.mockResolvedValue(1);
|
||||||
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
|
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
|
||||||
|
|
||||||
await sut.delete(authStub.admin, libraryStub.externalLibrary1.id);
|
await sut.delete(libraryStub.externalLibrary1.id);
|
||||||
|
|
||||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||||
name: JobName.LIBRARY_DELETE,
|
name: JobName.LIBRARY_DELETE,
|
||||||
@ -757,26 +755,16 @@ describe(LibraryService.name, () => {
|
|||||||
storageMock.watch.mockImplementation(makeMockWatcher({ close: mockClose }));
|
storageMock.watch.mockImplementation(makeMockWatcher({ close: mockClose }));
|
||||||
|
|
||||||
await sut.init();
|
await sut.init();
|
||||||
await sut.delete(authStub.admin, libraryStub.externalLibraryWithImportPaths1.id);
|
await sut.delete(libraryStub.externalLibraryWithImportPaths1.id);
|
||||||
|
|
||||||
expect(mockClose).toHaveBeenCalled();
|
expect(mockClose).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getCount', () => {
|
|
||||||
it('should call the repository', async () => {
|
|
||||||
libraryMock.getCountForUser.mockResolvedValue(17);
|
|
||||||
|
|
||||||
await expect(sut.getCount(authStub.admin)).resolves.toBe(17);
|
|
||||||
|
|
||||||
expect(libraryMock.getCountForUser).toHaveBeenCalledWith(authStub.admin.user.id);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('get', () => {
|
describe('get', () => {
|
||||||
it('should return a library', async () => {
|
it('should return a library', async () => {
|
||||||
libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1);
|
libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1);
|
||||||
await expect(sut.get(authStub.admin, libraryStub.uploadLibrary1.id)).resolves.toEqual(
|
await expect(sut.get(libraryStub.uploadLibrary1.id)).resolves.toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: libraryStub.uploadLibrary1.id,
|
id: libraryStub.uploadLibrary1.id,
|
||||||
name: libraryStub.uploadLibrary1.name,
|
name: libraryStub.uploadLibrary1.name,
|
||||||
@ -789,15 +777,16 @@ describe(LibraryService.name, () => {
|
|||||||
|
|
||||||
it('should throw an error when a library is not found', async () => {
|
it('should throw an error when a library is not found', async () => {
|
||||||
libraryMock.get.mockResolvedValue(null);
|
libraryMock.get.mockResolvedValue(null);
|
||||||
await expect(sut.get(authStub.admin, libraryStub.uploadLibrary1.id)).rejects.toBeInstanceOf(BadRequestException);
|
await expect(sut.get(libraryStub.uploadLibrary1.id)).rejects.toBeInstanceOf(BadRequestException);
|
||||||
expect(libraryMock.get).toHaveBeenCalledWith(libraryStub.uploadLibrary1.id);
|
expect(libraryMock.get).toHaveBeenCalledWith(libraryStub.uploadLibrary1.id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getStatistics', () => {
|
describe('getStatistics', () => {
|
||||||
it('should return library statistics', async () => {
|
it('should return library statistics', async () => {
|
||||||
|
libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1);
|
||||||
libraryMock.getStatistics.mockResolvedValue({ photos: 10, videos: 0, total: 10, usage: 1337 });
|
libraryMock.getStatistics.mockResolvedValue({ photos: 10, videos: 0, total: 10, usage: 1337 });
|
||||||
await expect(sut.getStatistics(authStub.admin, libraryStub.uploadLibrary1.id)).resolves.toEqual({
|
await expect(sut.getStatistics(libraryStub.uploadLibrary1.id)).resolves.toEqual({
|
||||||
photos: 10,
|
photos: 10,
|
||||||
videos: 0,
|
videos: 0,
|
||||||
total: 10,
|
total: 10,
|
||||||
@ -812,11 +801,7 @@ describe(LibraryService.name, () => {
|
|||||||
describe('external library', () => {
|
describe('external library', () => {
|
||||||
it('should create with default settings', async () => {
|
it('should create with default settings', async () => {
|
||||||
libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1);
|
libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1);
|
||||||
await expect(
|
await expect(sut.create({ ownerId: authStub.admin.user.id, type: LibraryType.EXTERNAL })).resolves.toEqual(
|
||||||
sut.create(authStub.admin, {
|
|
||||||
type: LibraryType.EXTERNAL,
|
|
||||||
}),
|
|
||||||
).resolves.toEqual(
|
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: libraryStub.externalLibrary1.id,
|
id: libraryStub.externalLibrary1.id,
|
||||||
type: LibraryType.EXTERNAL,
|
type: LibraryType.EXTERNAL,
|
||||||
@ -845,10 +830,7 @@ describe(LibraryService.name, () => {
|
|||||||
it('should create with name', async () => {
|
it('should create with name', async () => {
|
||||||
libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1);
|
libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1);
|
||||||
await expect(
|
await expect(
|
||||||
sut.create(authStub.admin, {
|
sut.create({ ownerId: authStub.admin.user.id, type: LibraryType.EXTERNAL, name: 'My Awesome Library' }),
|
||||||
type: LibraryType.EXTERNAL,
|
|
||||||
name: 'My Awesome Library',
|
|
||||||
}),
|
|
||||||
).resolves.toEqual(
|
).resolves.toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: libraryStub.externalLibrary1.id,
|
id: libraryStub.externalLibrary1.id,
|
||||||
@ -878,10 +860,7 @@ describe(LibraryService.name, () => {
|
|||||||
it('should create invisible', async () => {
|
it('should create invisible', async () => {
|
||||||
libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1);
|
libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1);
|
||||||
await expect(
|
await expect(
|
||||||
sut.create(authStub.admin, {
|
sut.create({ ownerId: authStub.admin.user.id, type: LibraryType.EXTERNAL, isVisible: false }),
|
||||||
type: LibraryType.EXTERNAL,
|
|
||||||
isVisible: false,
|
|
||||||
}),
|
|
||||||
).resolves.toEqual(
|
).resolves.toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: libraryStub.externalLibrary1.id,
|
id: libraryStub.externalLibrary1.id,
|
||||||
@ -911,7 +890,8 @@ describe(LibraryService.name, () => {
|
|||||||
it('should create with import paths', async () => {
|
it('should create with import paths', async () => {
|
||||||
libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1);
|
libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1);
|
||||||
await expect(
|
await expect(
|
||||||
sut.create(authStub.admin, {
|
sut.create({
|
||||||
|
ownerId: authStub.admin.user.id,
|
||||||
type: LibraryType.EXTERNAL,
|
type: LibraryType.EXTERNAL,
|
||||||
importPaths: ['/data/images', '/data/videos'],
|
importPaths: ['/data/images', '/data/videos'],
|
||||||
}),
|
}),
|
||||||
@ -948,7 +928,8 @@ describe(LibraryService.name, () => {
|
|||||||
libraryMock.getAll.mockResolvedValue([]);
|
libraryMock.getAll.mockResolvedValue([]);
|
||||||
|
|
||||||
await sut.init();
|
await sut.init();
|
||||||
await sut.create(authStub.admin, {
|
await sut.create({
|
||||||
|
ownerId: authStub.admin.user.id,
|
||||||
type: LibraryType.EXTERNAL,
|
type: LibraryType.EXTERNAL,
|
||||||
importPaths: libraryStub.externalLibraryWithImportPaths1.importPaths,
|
importPaths: libraryStub.externalLibraryWithImportPaths1.importPaths,
|
||||||
});
|
});
|
||||||
@ -963,7 +944,8 @@ describe(LibraryService.name, () => {
|
|||||||
it('should create with exclusion patterns', async () => {
|
it('should create with exclusion patterns', async () => {
|
||||||
libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1);
|
libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1);
|
||||||
await expect(
|
await expect(
|
||||||
sut.create(authStub.admin, {
|
sut.create({
|
||||||
|
ownerId: authStub.admin.user.id,
|
||||||
type: LibraryType.EXTERNAL,
|
type: LibraryType.EXTERNAL,
|
||||||
exclusionPatterns: ['*.tmp', '*.bak'],
|
exclusionPatterns: ['*.tmp', '*.bak'],
|
||||||
}),
|
}),
|
||||||
@ -997,11 +979,7 @@ describe(LibraryService.name, () => {
|
|||||||
describe('upload library', () => {
|
describe('upload library', () => {
|
||||||
it('should create with default settings', async () => {
|
it('should create with default settings', async () => {
|
||||||
libraryMock.create.mockResolvedValue(libraryStub.uploadLibrary1);
|
libraryMock.create.mockResolvedValue(libraryStub.uploadLibrary1);
|
||||||
await expect(
|
await expect(sut.create({ ownerId: authStub.admin.user.id, type: LibraryType.UPLOAD })).resolves.toEqual(
|
||||||
sut.create(authStub.admin, {
|
|
||||||
type: LibraryType.UPLOAD,
|
|
||||||
}),
|
|
||||||
).resolves.toEqual(
|
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: libraryStub.uploadLibrary1.id,
|
id: libraryStub.uploadLibrary1.id,
|
||||||
type: LibraryType.UPLOAD,
|
type: LibraryType.UPLOAD,
|
||||||
@ -1030,10 +1008,7 @@ describe(LibraryService.name, () => {
|
|||||||
it('should create with name', async () => {
|
it('should create with name', async () => {
|
||||||
libraryMock.create.mockResolvedValue(libraryStub.uploadLibrary1);
|
libraryMock.create.mockResolvedValue(libraryStub.uploadLibrary1);
|
||||||
await expect(
|
await expect(
|
||||||
sut.create(authStub.admin, {
|
sut.create({ ownerId: authStub.admin.user.id, type: LibraryType.UPLOAD, name: 'My Awesome Library' }),
|
||||||
type: LibraryType.UPLOAD,
|
|
||||||
name: 'My Awesome Library',
|
|
||||||
}),
|
|
||||||
).resolves.toEqual(
|
).resolves.toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: libraryStub.uploadLibrary1.id,
|
id: libraryStub.uploadLibrary1.id,
|
||||||
@ -1062,7 +1037,8 @@ describe(LibraryService.name, () => {
|
|||||||
|
|
||||||
it('should not create with import paths', async () => {
|
it('should not create with import paths', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
sut.create(authStub.admin, {
|
sut.create({
|
||||||
|
ownerId: authStub.admin.user.id,
|
||||||
type: LibraryType.UPLOAD,
|
type: LibraryType.UPLOAD,
|
||||||
importPaths: ['/data/images', '/data/videos'],
|
importPaths: ['/data/images', '/data/videos'],
|
||||||
}),
|
}),
|
||||||
@ -1073,7 +1049,8 @@ describe(LibraryService.name, () => {
|
|||||||
|
|
||||||
it('should not create with exclusion patterns', async () => {
|
it('should not create with exclusion patterns', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
sut.create(authStub.admin, {
|
sut.create({
|
||||||
|
ownerId: authStub.admin.user.id,
|
||||||
type: LibraryType.UPLOAD,
|
type: LibraryType.UPLOAD,
|
||||||
exclusionPatterns: ['*.tmp', '*.bak'],
|
exclusionPatterns: ['*.tmp', '*.bak'],
|
||||||
}),
|
}),
|
||||||
@ -1084,10 +1061,7 @@ describe(LibraryService.name, () => {
|
|||||||
|
|
||||||
it('should not create watched', async () => {
|
it('should not create watched', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
sut.create(authStub.admin, {
|
sut.create({ ownerId: authStub.admin.user.id, type: LibraryType.UPLOAD, isWatched: true }),
|
||||||
type: LibraryType.UPLOAD,
|
|
||||||
isWatched: true,
|
|
||||||
}),
|
|
||||||
).rejects.toBeInstanceOf(BadRequestException);
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
|
||||||
expect(storageMock.watch).not.toHaveBeenCalled();
|
expect(storageMock.watch).not.toHaveBeenCalled();
|
||||||
@ -1117,14 +1091,9 @@ describe(LibraryService.name, () => {
|
|||||||
|
|
||||||
it('should update library', async () => {
|
it('should update library', async () => {
|
||||||
libraryMock.update.mockResolvedValue(libraryStub.uploadLibrary1);
|
libraryMock.update.mockResolvedValue(libraryStub.uploadLibrary1);
|
||||||
await expect(sut.update(authStub.admin, authStub.admin.user.id, {})).resolves.toEqual(
|
libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1);
|
||||||
mapLibrary(libraryStub.uploadLibrary1),
|
await expect(sut.update('library-id', {})).resolves.toEqual(mapLibrary(libraryStub.uploadLibrary1));
|
||||||
);
|
expect(libraryMock.update).toHaveBeenCalledWith(expect.objectContaining({ id: 'library-id' }));
|
||||||
expect(libraryMock.update).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
id: authStub.admin.user.id,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should re-watch library when updating import paths', async () => {
|
it('should re-watch library when updating import paths', async () => {
|
||||||
@ -1137,15 +1106,11 @@ describe(LibraryService.name, () => {
|
|||||||
|
|
||||||
storageMock.checkFileExists.mockResolvedValue(true);
|
storageMock.checkFileExists.mockResolvedValue(true);
|
||||||
|
|
||||||
await expect(
|
await expect(sut.update('library-id', { importPaths: ['/data/user1/foo'] })).resolves.toEqual(
|
||||||
sut.update(authStub.admin, authStub.admin.user.id, { importPaths: ['/data/user1/foo'] }),
|
mapLibrary(libraryStub.externalLibraryWithImportPaths1),
|
||||||
).resolves.toEqual(mapLibrary(libraryStub.externalLibraryWithImportPaths1));
|
|
||||||
|
|
||||||
expect(libraryMock.update).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
id: authStub.admin.user.id,
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
expect(libraryMock.update).toHaveBeenCalledWith(expect.objectContaining({ id: 'library-id' }));
|
||||||
expect(storageMock.watch).toHaveBeenCalledWith(
|
expect(storageMock.watch).toHaveBeenCalledWith(
|
||||||
libraryStub.externalLibraryWithImportPaths1.importPaths,
|
libraryStub.externalLibraryWithImportPaths1.importPaths,
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
@ -1158,15 +1123,11 @@ describe(LibraryService.name, () => {
|
|||||||
configMock.load.mockResolvedValue(systemConfigStub.libraryWatchEnabled);
|
configMock.load.mockResolvedValue(systemConfigStub.libraryWatchEnabled);
|
||||||
libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
|
libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
|
||||||
|
|
||||||
await expect(sut.update(authStub.admin, authStub.admin.user.id, { exclusionPatterns: ['bar'] })).resolves.toEqual(
|
await expect(sut.update('library-id', { exclusionPatterns: ['bar'] })).resolves.toEqual(
|
||||||
mapLibrary(libraryStub.externalLibraryWithImportPaths1),
|
mapLibrary(libraryStub.externalLibraryWithImportPaths1),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(libraryMock.update).toHaveBeenCalledWith(
|
expect(libraryMock.update).toHaveBeenCalledWith(expect.objectContaining({ id: 'library-id' }));
|
||||||
expect.objectContaining({
|
|
||||||
id: authStub.admin.user.id,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(storageMock.watch).toHaveBeenCalledWith(
|
expect(storageMock.watch).toHaveBeenCalledWith(
|
||||||
expect.arrayContaining([expect.any(String)]),
|
expect.arrayContaining([expect.any(String)]),
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
@ -1411,7 +1372,7 @@ describe(LibraryService.name, () => {
|
|||||||
it('should queue a library scan of external library', async () => {
|
it('should queue a library scan of external library', async () => {
|
||||||
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
|
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
|
||||||
|
|
||||||
await sut.queueScan(authStub.admin, libraryStub.externalLibrary1.id, {});
|
await sut.queueScan(libraryStub.externalLibrary1.id, {});
|
||||||
|
|
||||||
expect(jobMock.queue.mock.calls).toEqual([
|
expect(jobMock.queue.mock.calls).toEqual([
|
||||||
[
|
[
|
||||||
@ -1430,9 +1391,7 @@ describe(LibraryService.name, () => {
|
|||||||
it('should not queue a library scan of upload library', async () => {
|
it('should not queue a library scan of upload library', async () => {
|
||||||
libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1);
|
libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1);
|
||||||
|
|
||||||
await expect(sut.queueScan(authStub.admin, libraryStub.uploadLibrary1.id, {})).rejects.toBeInstanceOf(
|
await expect(sut.queueScan(libraryStub.uploadLibrary1.id, {})).rejects.toBeInstanceOf(BadRequestException);
|
||||||
BadRequestException,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(jobMock.queue).not.toBeCalled();
|
expect(jobMock.queue).not.toBeCalled();
|
||||||
});
|
});
|
||||||
@ -1440,7 +1399,7 @@ describe(LibraryService.name, () => {
|
|||||||
it('should queue a library scan of all modified assets', async () => {
|
it('should queue a library scan of all modified assets', async () => {
|
||||||
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
|
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
|
||||||
|
|
||||||
await sut.queueScan(authStub.admin, libraryStub.externalLibrary1.id, { refreshModifiedFiles: true });
|
await sut.queueScan(libraryStub.externalLibrary1.id, { refreshModifiedFiles: true });
|
||||||
|
|
||||||
expect(jobMock.queue.mock.calls).toEqual([
|
expect(jobMock.queue.mock.calls).toEqual([
|
||||||
[
|
[
|
||||||
@ -1459,7 +1418,7 @@ describe(LibraryService.name, () => {
|
|||||||
it('should queue a forced library scan', async () => {
|
it('should queue a forced library scan', async () => {
|
||||||
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
|
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
|
||||||
|
|
||||||
await sut.queueScan(authStub.admin, libraryStub.externalLibrary1.id, { refreshAllFiles: true });
|
await sut.queueScan(libraryStub.externalLibrary1.id, { refreshAllFiles: true });
|
||||||
|
|
||||||
expect(jobMock.queue.mock.calls).toEqual([
|
expect(jobMock.queue.mock.calls).toEqual([
|
||||||
[
|
[
|
||||||
@ -1478,7 +1437,7 @@ describe(LibraryService.name, () => {
|
|||||||
|
|
||||||
describe('queueEmptyTrash', () => {
|
describe('queueEmptyTrash', () => {
|
||||||
it('should queue the trash job', async () => {
|
it('should queue the trash job', async () => {
|
||||||
await sut.queueRemoveOffline(authStub.admin, libraryStub.externalLibrary1.id);
|
await sut.queueRemoveOffline(libraryStub.externalLibrary1.id);
|
||||||
|
|
||||||
expect(jobMock.queue.mock.calls).toEqual([
|
expect(jobMock.queue.mock.calls).toEqual([
|
||||||
[
|
[
|
||||||
@ -1566,17 +1525,15 @@ describe(LibraryService.name, () => {
|
|||||||
|
|
||||||
storageMock.checkFileExists.mockResolvedValue(true);
|
storageMock.checkFileExists.mockResolvedValue(true);
|
||||||
|
|
||||||
const result = await sut.validate(authStub.external1, libraryStub.externalLibraryWithImportPaths1.id, {
|
await expect(sut.validate('library-id', { importPaths: ['/data/user1/'] })).resolves.toEqual({
|
||||||
importPaths: ['/data/user1/'],
|
importPaths: [
|
||||||
|
{
|
||||||
|
importPath: '/data/user1/',
|
||||||
|
isValid: true,
|
||||||
|
message: undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.importPaths).toEqual([
|
|
||||||
{
|
|
||||||
importPath: '/data/user1/',
|
|
||||||
isValid: true,
|
|
||||||
message: undefined,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should detect when path does not exist', async () => {
|
it('should detect when path does not exist', async () => {
|
||||||
@ -1585,17 +1542,15 @@ describe(LibraryService.name, () => {
|
|||||||
throw error;
|
throw error;
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await sut.validate(authStub.external1, libraryStub.externalLibraryWithImportPaths1.id, {
|
await expect(sut.validate('library-id', { importPaths: ['/data/user1/'] })).resolves.toEqual({
|
||||||
importPaths: ['/data/user1/'],
|
importPaths: [
|
||||||
|
{
|
||||||
|
importPath: '/data/user1/',
|
||||||
|
isValid: false,
|
||||||
|
message: 'Path does not exist (ENOENT)',
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.importPaths).toEqual([
|
|
||||||
{
|
|
||||||
importPath: '/data/user1/',
|
|
||||||
isValid: false,
|
|
||||||
message: 'Path does not exist (ENOENT)',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should detect when path is not a directory', async () => {
|
it('should detect when path is not a directory', async () => {
|
||||||
@ -1603,17 +1558,15 @@ describe(LibraryService.name, () => {
|
|||||||
isDirectory: () => false,
|
isDirectory: () => false,
|
||||||
} as Stats);
|
} as Stats);
|
||||||
|
|
||||||
const result = await sut.validate(authStub.external1, libraryStub.externalLibraryWithImportPaths1.id, {
|
await expect(sut.validate('library-id', { importPaths: ['/data/user1/file'] })).resolves.toEqual({
|
||||||
importPaths: ['/data/user1/file'],
|
importPaths: [
|
||||||
|
{
|
||||||
|
importPath: '/data/user1/file',
|
||||||
|
isValid: false,
|
||||||
|
message: 'Not a directory',
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.importPaths).toEqual([
|
|
||||||
{
|
|
||||||
importPath: '/data/user1/file',
|
|
||||||
isValid: false,
|
|
||||||
message: 'Not a directory',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return an unknown exception from stat', async () => {
|
it('should return an unknown exception from stat', async () => {
|
||||||
@ -1621,17 +1574,15 @@ describe(LibraryService.name, () => {
|
|||||||
throw new Error('Unknown error');
|
throw new Error('Unknown error');
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await sut.validate(authStub.external1, libraryStub.externalLibraryWithImportPaths1.id, {
|
await expect(sut.validate('library-id', { importPaths: ['/data/user1/'] })).resolves.toEqual({
|
||||||
importPaths: ['/data/user1/'],
|
importPaths: [
|
||||||
|
{
|
||||||
|
importPath: '/data/user1/',
|
||||||
|
isValid: false,
|
||||||
|
message: 'Error: Unknown error',
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.importPaths).toEqual([
|
|
||||||
{
|
|
||||||
importPath: '/data/user1/',
|
|
||||||
isValid: false,
|
|
||||||
message: 'Error: Unknown error',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should detect when access rights are missing', async () => {
|
it('should detect when access rights are missing', async () => {
|
||||||
@ -1641,17 +1592,15 @@ describe(LibraryService.name, () => {
|
|||||||
|
|
||||||
storageMock.checkFileExists.mockResolvedValue(false);
|
storageMock.checkFileExists.mockResolvedValue(false);
|
||||||
|
|
||||||
const result = await sut.validate(authStub.external1, libraryStub.externalLibraryWithImportPaths1.id, {
|
await expect(sut.validate('library-id', { importPaths: ['/data/user1/'] })).resolves.toEqual({
|
||||||
importPaths: ['/data/user1/'],
|
importPaths: [
|
||||||
|
{
|
||||||
|
importPath: '/data/user1/',
|
||||||
|
isValid: false,
|
||||||
|
message: 'Lacking read permission for folder',
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.importPaths).toEqual([
|
|
||||||
{
|
|
||||||
importPath: '/data/user1/',
|
|
||||||
isValid: false,
|
|
||||||
message: 'Lacking read permission for folder',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should detect when import path is in immich media folder', async () => {
|
it('should detect when import path is in immich media folder', async () => {
|
||||||
@ -1659,26 +1608,26 @@ describe(LibraryService.name, () => {
|
|||||||
const validImport = libraryStub.hasImmichPaths.importPaths[1];
|
const validImport = libraryStub.hasImmichPaths.importPaths[1];
|
||||||
when(storageMock.checkFileExists).calledWith(validImport, R_OK).mockResolvedValue(true);
|
when(storageMock.checkFileExists).calledWith(validImport, R_OK).mockResolvedValue(true);
|
||||||
|
|
||||||
const result = await sut.validate(authStub.external1, libraryStub.hasImmichPaths.id, {
|
await expect(
|
||||||
importPaths: libraryStub.hasImmichPaths.importPaths,
|
sut.validate('library-id', { importPaths: libraryStub.hasImmichPaths.importPaths }),
|
||||||
|
).resolves.toEqual({
|
||||||
|
importPaths: [
|
||||||
|
{
|
||||||
|
importPath: libraryStub.hasImmichPaths.importPaths[0],
|
||||||
|
isValid: false,
|
||||||
|
message: 'Cannot use media upload folder for external libraries',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
importPath: validImport,
|
||||||
|
isValid: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
importPath: libraryStub.hasImmichPaths.importPaths[2],
|
||||||
|
isValid: false,
|
||||||
|
message: 'Cannot use media upload folder for external libraries',
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.importPaths).toEqual([
|
|
||||||
{
|
|
||||||
importPath: libraryStub.hasImmichPaths.importPaths[0],
|
|
||||||
isValid: false,
|
|
||||||
message: 'Cannot use media upload folder for external libraries',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
importPath: validImport,
|
|
||||||
isValid: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
importPath: libraryStub.hasImmichPaths.importPaths[2],
|
|
||||||
isValid: false,
|
|
||||||
message: 'Cannot use media upload folder for external libraries',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -8,8 +8,7 @@ import { EventEmitter } from 'node:events';
|
|||||||
import { Stats } from 'node:fs';
|
import { Stats } from 'node:fs';
|
||||||
import path, { basename, parse } from 'node:path';
|
import path, { basename, parse } from 'node:path';
|
||||||
import picomatch from 'picomatch';
|
import picomatch from 'picomatch';
|
||||||
import { AccessCore, Permission } from '../access';
|
import { AccessCore } from '../access';
|
||||||
import { AuthDto } from '../auth';
|
|
||||||
import { mimeTypes } from '../domain.constant';
|
import { mimeTypes } from '../domain.constant';
|
||||||
import { handlePromiseError, usePagination, validateCronExpression } from '../domain.util';
|
import { handlePromiseError, usePagination, validateCronExpression } from '../domain.util';
|
||||||
import { IBaseJob, IEntityJob, ILibraryFileJob, ILibraryRefreshJob, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job';
|
import { IBaseJob, IEntityJob, ILibraryFileJob, ILibraryRefreshJob, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job';
|
||||||
@ -226,24 +225,17 @@ export class LibraryService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getStatistics(auth: AuthDto, id: string): Promise<LibraryStatsResponseDto> {
|
async getStatistics(id: string): Promise<LibraryStatsResponseDto> {
|
||||||
await this.access.requirePermission(auth, Permission.LIBRARY_READ, id);
|
await this.findOrFail(id);
|
||||||
|
|
||||||
return this.repository.getStatistics(id);
|
return this.repository.getStatistics(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCount(auth: AuthDto): Promise<number> {
|
async get(id: string): Promise<LibraryResponseDto> {
|
||||||
return this.repository.getCountForUser(auth.user.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
async get(auth: AuthDto, id: string): Promise<LibraryResponseDto> {
|
|
||||||
await this.access.requirePermission(auth, Permission.LIBRARY_READ, id);
|
|
||||||
|
|
||||||
const library = await this.findOrFail(id);
|
const library = await this.findOrFail(id);
|
||||||
return mapLibrary(library);
|
return mapLibrary(library);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAll(auth: AuthDto, dto: SearchLibraryDto): Promise<LibraryResponseDto[]> {
|
async getAll(dto: SearchLibraryDto): Promise<LibraryResponseDto[]> {
|
||||||
const libraries = await this.repository.getAll(false, dto.type);
|
const libraries = await this.repository.getAll(false, dto.type);
|
||||||
return libraries.map((library) => mapLibrary(library));
|
return libraries.map((library) => mapLibrary(library));
|
||||||
}
|
}
|
||||||
@ -257,7 +249,7 @@ export class LibraryService extends EventEmitter {
|
|||||||
return JobStatus.SUCCESS;
|
return JobStatus.SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(auth: AuthDto, dto: CreateLibraryDto): Promise<LibraryResponseDto> {
|
async create(dto: CreateLibraryDto): Promise<LibraryResponseDto> {
|
||||||
switch (dto.type) {
|
switch (dto.type) {
|
||||||
case LibraryType.EXTERNAL: {
|
case LibraryType.EXTERNAL: {
|
||||||
if (!dto.name) {
|
if (!dto.name) {
|
||||||
@ -282,14 +274,8 @@ export class LibraryService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let ownerId = auth.user.id;
|
|
||||||
|
|
||||||
if (dto.ownerId) {
|
|
||||||
ownerId = dto.ownerId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const library = await this.repository.create({
|
const library = await this.repository.create({
|
||||||
ownerId,
|
ownerId: dto.ownerId,
|
||||||
name: dto.name,
|
name: dto.name,
|
||||||
type: dto.type,
|
type: dto.type,
|
||||||
importPaths: dto.importPaths ?? [],
|
importPaths: dto.importPaths ?? [],
|
||||||
@ -297,7 +283,7 @@ export class LibraryService extends EventEmitter {
|
|||||||
isVisible: dto.isVisible ?? true,
|
isVisible: dto.isVisible ?? true,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(`Creating ${dto.type} library for user ${auth.user.name}`);
|
this.logger.log(`Creating ${dto.type} library for ${dto.ownerId}}`);
|
||||||
|
|
||||||
if (dto.type === LibraryType.EXTERNAL) {
|
if (dto.type === LibraryType.EXTERNAL) {
|
||||||
await this.watch(library.id);
|
await this.watch(library.id);
|
||||||
@ -364,29 +350,19 @@ export class LibraryService extends EventEmitter {
|
|||||||
return validation;
|
return validation;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async validate(auth: AuthDto, id: string, dto: ValidateLibraryDto): Promise<ValidateLibraryResponseDto> {
|
async validate(id: string, dto: ValidateLibraryDto): Promise<ValidateLibraryResponseDto> {
|
||||||
await this.access.requirePermission(auth, Permission.LIBRARY_UPDATE, id);
|
const importPaths = await Promise.all(
|
||||||
|
(dto.importPaths || []).map((importPath) => this.validateImportPath(importPath)),
|
||||||
const response = new ValidateLibraryResponseDto();
|
);
|
||||||
|
return { importPaths };
|
||||||
if (dto.importPaths) {
|
|
||||||
response.importPaths = await Promise.all(
|
|
||||||
dto.importPaths.map(async (importPath) => {
|
|
||||||
return await this.validateImportPath(importPath);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(auth: AuthDto, id: string, dto: UpdateLibraryDto): Promise<LibraryResponseDto> {
|
async update(id: string, dto: UpdateLibraryDto): Promise<LibraryResponseDto> {
|
||||||
await this.access.requirePermission(auth, Permission.LIBRARY_UPDATE, id);
|
await this.findOrFail(id);
|
||||||
|
|
||||||
const library = await this.repository.update({ id, ...dto });
|
const library = await this.repository.update({ id, ...dto });
|
||||||
|
|
||||||
if (dto.importPaths) {
|
if (dto.importPaths) {
|
||||||
const validation = await this.validate(auth, id, { importPaths: dto.importPaths });
|
const validation = await this.validate(id, { importPaths: dto.importPaths });
|
||||||
if (validation.importPaths) {
|
if (validation.importPaths) {
|
||||||
for (const path of validation.importPaths) {
|
for (const path of validation.importPaths) {
|
||||||
if (!path.isValid) {
|
if (!path.isValid) {
|
||||||
@ -404,11 +380,9 @@ export class LibraryService extends EventEmitter {
|
|||||||
return mapLibrary(library);
|
return mapLibrary(library);
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(auth: AuthDto, id: string) {
|
async delete(id: string) {
|
||||||
await this.access.requirePermission(auth, Permission.LIBRARY_DELETE, id);
|
|
||||||
|
|
||||||
const library = await this.findOrFail(id);
|
const library = await this.findOrFail(id);
|
||||||
const uploadCount = await this.repository.getUploadLibraryCount(auth.user.id);
|
const uploadCount = await this.repository.getUploadLibraryCount(library.ownerId);
|
||||||
if (library.type === LibraryType.UPLOAD && uploadCount <= 1) {
|
if (library.type === LibraryType.UPLOAD && uploadCount <= 1) {
|
||||||
throw new BadRequestException('Cannot delete the last upload library');
|
throw new BadRequestException('Cannot delete the last upload library');
|
||||||
}
|
}
|
||||||
@ -565,11 +539,9 @@ export class LibraryService extends EventEmitter {
|
|||||||
return JobStatus.SUCCESS;
|
return JobStatus.SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
async queueScan(auth: AuthDto, id: string, dto: ScanLibraryDto) {
|
async queueScan(id: string, dto: ScanLibraryDto) {
|
||||||
await this.access.requirePermission(auth, Permission.LIBRARY_UPDATE, id);
|
const library = await this.findOrFail(id);
|
||||||
|
if (library.type !== LibraryType.EXTERNAL) {
|
||||||
const library = await this.repository.get(id);
|
|
||||||
if (!library || library.type !== LibraryType.EXTERNAL) {
|
|
||||||
throw new BadRequestException('Can only refresh external libraries');
|
throw new BadRequestException('Can only refresh external libraries');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -583,16 +555,9 @@ export class LibraryService extends EventEmitter {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async queueRemoveOffline(auth: AuthDto, id: string) {
|
async queueRemoveOffline(id: string) {
|
||||||
this.logger.verbose(`Removing offline files from library: ${id}`);
|
this.logger.verbose(`Removing offline files from library: ${id}`);
|
||||||
await this.access.requirePermission(auth, Permission.LIBRARY_UPDATE, id);
|
await this.jobRepository.queue({ name: JobName.LIBRARY_REMOVE_OFFLINE, data: { id } });
|
||||||
|
|
||||||
await this.jobRepository.queue({
|
|
||||||
name: JobName.LIBRARY_REMOVE_OFFLINE,
|
|
||||||
data: {
|
|
||||||
id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleQueueAllScan(job: IBaseJob): Promise<JobStatus> {
|
async handleQueueAllScan(job: IBaseJob): Promise<JobStatus> {
|
||||||
|
@ -26,7 +26,6 @@ export interface IAccessRepository {
|
|||||||
|
|
||||||
library: {
|
library: {
|
||||||
checkOwnerAccess(userId: string, libraryIds: Set<string>): Promise<Set<string>>;
|
checkOwnerAccess(userId: string, libraryIds: Set<string>): Promise<Set<string>>;
|
||||||
checkPartnerAccess(userId: string, partnerIds: Set<string>): Promise<Set<string>>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
timeline: {
|
timeline: {
|
||||||
|
@ -123,7 +123,7 @@ export interface IAssetRepository {
|
|||||||
select?: FindOptionsSelect<AssetEntity>,
|
select?: FindOptionsSelect<AssetEntity>,
|
||||||
): Promise<AssetEntity[]>;
|
): Promise<AssetEntity[]>;
|
||||||
getByIdsWithAllRelations(ids: string[]): Promise<AssetEntity[]>;
|
getByIdsWithAllRelations(ids: string[]): Promise<AssetEntity[]>;
|
||||||
getByDayOfYear(ownerId: string, monthDay: MonthDay): Promise<AssetEntity[]>;
|
getByDayOfYear(ownerIds: string[], monthDay: MonthDay): Promise<AssetEntity[]>;
|
||||||
getByChecksum(userId: string, checksum: Buffer): Promise<AssetEntity | null>;
|
getByChecksum(userId: string, checksum: Buffer): Promise<AssetEntity | null>;
|
||||||
getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated<AssetEntity>;
|
getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated<AssetEntity>;
|
||||||
getByUserId(pagination: PaginationOptions, userId: string, options?: AssetSearchOptions): Paginated<AssetEntity>;
|
getByUserId(pagination: PaginationOptions, userId: string, options?: AssetSearchOptions): Paginated<AssetEntity>;
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
AuthDto,
|
|
||||||
CreateLibraryDto as CreateDto,
|
CreateLibraryDto as CreateDto,
|
||||||
LibraryService,
|
LibraryService,
|
||||||
LibraryStatsResponseDto,
|
LibraryStatsResponseDto,
|
||||||
@ -12,7 +11,7 @@ import {
|
|||||||
} from '@app/domain';
|
} from '@app/domain';
|
||||||
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common';
|
||||||
import { ApiTags } from '@nestjs/swagger';
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
import { AdminRoute, Auth, Authenticated } from '../app.guard';
|
import { AdminRoute, Authenticated } from '../app.guard';
|
||||||
import { UUIDParamDto } from './dto/uuid-param.dto';
|
import { UUIDParamDto } from './dto/uuid-param.dto';
|
||||||
|
|
||||||
@ApiTags('Library')
|
@ApiTags('Library')
|
||||||
@ -23,55 +22,52 @@ export class LibraryController {
|
|||||||
constructor(private service: LibraryService) {}
|
constructor(private service: LibraryService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
getAllLibraries(@Auth() auth: AuthDto, @Query() dto: SearchLibraryDto): Promise<ResponseDto[]> {
|
getAllLibraries(@Query() dto: SearchLibraryDto): Promise<ResponseDto[]> {
|
||||||
return this.service.getAll(auth, dto);
|
return this.service.getAll(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
createLibrary(@Auth() auth: AuthDto, @Body() dto: CreateDto): Promise<ResponseDto> {
|
createLibrary(@Body() dto: CreateDto): Promise<ResponseDto> {
|
||||||
return this.service.create(auth, dto);
|
return this.service.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
updateLibrary(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Body() dto: UpdateDto): Promise<ResponseDto> {
|
updateLibrary(@Param() { id }: UUIDParamDto, @Body() dto: UpdateDto): Promise<ResponseDto> {
|
||||||
return this.service.update(auth, id, dto);
|
return this.service.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
getLibrary(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<ResponseDto> {
|
getLibrary(@Param() { id }: UUIDParamDto): Promise<ResponseDto> {
|
||||||
return this.service.get(auth, id);
|
return this.service.get(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/validate')
|
@Post(':id/validate')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
validate(
|
// TODO: change endpoint to validate current settings instead
|
||||||
@Auth() auth: AuthDto,
|
validate(@Param() { id }: UUIDParamDto, @Body() dto: ValidateLibraryDto): Promise<ValidateLibraryResponseDto> {
|
||||||
@Param() { id }: UUIDParamDto,
|
return this.service.validate(id, dto);
|
||||||
@Body() dto: ValidateLibraryDto,
|
|
||||||
): Promise<ValidateLibraryResponseDto> {
|
|
||||||
return this.service.validate(auth, id, dto);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
deleteLibrary(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
|
deleteLibrary(@Param() { id }: UUIDParamDto): Promise<void> {
|
||||||
return this.service.delete(auth, id);
|
return this.service.delete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id/statistics')
|
@Get(':id/statistics')
|
||||||
getLibraryStatistics(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<LibraryStatsResponseDto> {
|
getLibraryStatistics(@Param() { id }: UUIDParamDto): Promise<LibraryStatsResponseDto> {
|
||||||
return this.service.getStatistics(auth, id);
|
return this.service.getStatistics(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/scan')
|
@Post(':id/scan')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
scanLibrary(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Body() dto: ScanLibraryDto) {
|
scanLibrary(@Param() { id }: UUIDParamDto, @Body() dto: ScanLibraryDto) {
|
||||||
return this.service.queueScan(auth, id, dto);
|
return this.service.queueScan(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/removeOffline')
|
@Post(':id/removeOffline')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
removeOfflineFiles(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto) {
|
removeOfflineFiles(@Param() { id }: UUIDParamDto) {
|
||||||
return this.service.queueRemoveOffline(auth, id);
|
return this.service.queueRemoveOffline(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -307,10 +307,7 @@ class AuthDeviceAccess implements IAuthDeviceAccess {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class LibraryAccess implements ILibraryAccess {
|
class LibraryAccess implements ILibraryAccess {
|
||||||
constructor(
|
constructor(private libraryRepository: Repository<LibraryEntity>) {}
|
||||||
private libraryRepository: Repository<LibraryEntity>,
|
|
||||||
private partnerRepository: Repository<PartnerEntity>,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
|
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
|
||||||
@ChunkedSet({ paramIndex: 1 })
|
@ChunkedSet({ paramIndex: 1 })
|
||||||
@ -329,22 +326,6 @@ class LibraryAccess implements ILibraryAccess {
|
|||||||
})
|
})
|
||||||
.then((libraries) => new Set(libraries.map((library) => library.id)));
|
.then((libraries) => new Set(libraries.map((library) => library.id)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
|
|
||||||
@ChunkedSet({ paramIndex: 1 })
|
|
||||||
async checkPartnerAccess(userId: string, partnerIds: Set<string>): Promise<Set<string>> {
|
|
||||||
if (partnerIds.size === 0) {
|
|
||||||
return new Set();
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.partnerRepository
|
|
||||||
.createQueryBuilder('partner')
|
|
||||||
.select('partner.sharedById')
|
|
||||||
.where('partner.sharedById IN (:...partnerIds)', { partnerIds: [...partnerIds] })
|
|
||||||
.andWhere('partner.sharedWithId = :userId', { userId })
|
|
||||||
.getMany()
|
|
||||||
.then((partners) => new Set(partners.map((partner) => partner.sharedById)));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class TimelineAccess implements ITimelineAccess {
|
class TimelineAccess implements ITimelineAccess {
|
||||||
@ -457,7 +438,7 @@ export class AccessRepository implements IAccessRepository {
|
|||||||
this.album = new AlbumAccess(albumRepository, sharedLinkRepository);
|
this.album = new AlbumAccess(albumRepository, sharedLinkRepository);
|
||||||
this.asset = new AssetAccess(albumRepository, assetRepository, partnerRepository, sharedLinkRepository);
|
this.asset = new AssetAccess(albumRepository, assetRepository, partnerRepository, sharedLinkRepository);
|
||||||
this.authDevice = new AuthDeviceAccess(tokenRepository);
|
this.authDevice = new AuthDeviceAccess(tokenRepository);
|
||||||
this.library = new LibraryAccess(libraryRepository, partnerRepository);
|
this.library = new LibraryAccess(libraryRepository);
|
||||||
this.person = new PersonAccess(assetFaceRepository, personRepository);
|
this.person = new PersonAccess(assetFaceRepository, personRepository);
|
||||||
this.partner = new PartnerAccess(partnerRepository);
|
this.partner = new PartnerAccess(partnerRepository);
|
||||||
this.timeline = new TimelineAccess(partnerRepository);
|
this.timeline = new TimelineAccess(partnerRepository);
|
||||||
|
@ -109,18 +109,18 @@ export class AssetRepository implements IAssetRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID, { day: 1, month: 1 }] })
|
@GenerateSql({ params: [DummyValue.UUID, { day: 1, month: 1 }] })
|
||||||
getByDayOfYear(ownerId: string, { day, month }: MonthDay): Promise<AssetEntity[]> {
|
getByDayOfYear(ownerIds: string[], { day, month }: MonthDay): Promise<AssetEntity[]> {
|
||||||
return this.repository
|
return this.repository
|
||||||
.createQueryBuilder('entity')
|
.createQueryBuilder('entity')
|
||||||
.where(
|
.where(
|
||||||
`entity.ownerId = :ownerId
|
`entity.ownerId IN (:...ownerIds)
|
||||||
AND entity.isVisible = true
|
AND entity.isVisible = true
|
||||||
AND entity.isArchived = false
|
AND entity.isArchived = false
|
||||||
AND entity.resizePath IS NOT NULL
|
AND entity.resizePath IS NOT NULL
|
||||||
AND EXTRACT(DAY FROM entity.localDateTime AT TIME ZONE 'UTC') = :day
|
AND EXTRACT(DAY FROM entity.localDateTime AT TIME ZONE 'UTC') = :day
|
||||||
AND EXTRACT(MONTH FROM entity.localDateTime AT TIME ZONE 'UTC') = :month`,
|
AND EXTRACT(MONTH FROM entity.localDateTime AT TIME ZONE 'UTC') = :month`,
|
||||||
{
|
{
|
||||||
ownerId,
|
ownerIds,
|
||||||
day,
|
day,
|
||||||
month,
|
month,
|
||||||
},
|
},
|
||||||
|
@ -196,16 +196,6 @@ WHERE
|
|||||||
)
|
)
|
||||||
AND ("LibraryEntity"."deletedAt" IS NULL)
|
AND ("LibraryEntity"."deletedAt" IS NULL)
|
||||||
|
|
||||||
-- AccessRepository.library.checkPartnerAccess
|
|
||||||
SELECT
|
|
||||||
"partner"."sharedById" AS "partner_sharedById",
|
|
||||||
"partner"."sharedWithId" AS "partner_sharedWithId"
|
|
||||||
FROM
|
|
||||||
"partners" "partner"
|
|
||||||
WHERE
|
|
||||||
"partner"."sharedById" IN ($1)
|
|
||||||
AND "partner"."sharedWithId" = $2
|
|
||||||
|
|
||||||
-- AccessRepository.person.checkOwnerAccess
|
-- AccessRepository.person.checkOwnerAccess
|
||||||
SELECT
|
SELECT
|
||||||
"PersonEntity"."id" AS "PersonEntity_id"
|
"PersonEntity"."id" AS "PersonEntity_id"
|
||||||
|
@ -76,89 +76,6 @@ WHERE
|
|||||||
ORDER BY
|
ORDER BY
|
||||||
"AssetEntity"."fileCreatedAt" DESC
|
"AssetEntity"."fileCreatedAt" DESC
|
||||||
|
|
||||||
-- AssetRepository.getByDayOfYear
|
|
||||||
SELECT
|
|
||||||
"entity"."id" AS "entity_id",
|
|
||||||
"entity"."deviceAssetId" AS "entity_deviceAssetId",
|
|
||||||
"entity"."ownerId" AS "entity_ownerId",
|
|
||||||
"entity"."libraryId" AS "entity_libraryId",
|
|
||||||
"entity"."deviceId" AS "entity_deviceId",
|
|
||||||
"entity"."type" AS "entity_type",
|
|
||||||
"entity"."originalPath" AS "entity_originalPath",
|
|
||||||
"entity"."resizePath" AS "entity_resizePath",
|
|
||||||
"entity"."webpPath" AS "entity_webpPath",
|
|
||||||
"entity"."thumbhash" AS "entity_thumbhash",
|
|
||||||
"entity"."encodedVideoPath" AS "entity_encodedVideoPath",
|
|
||||||
"entity"."createdAt" AS "entity_createdAt",
|
|
||||||
"entity"."updatedAt" AS "entity_updatedAt",
|
|
||||||
"entity"."deletedAt" AS "entity_deletedAt",
|
|
||||||
"entity"."fileCreatedAt" AS "entity_fileCreatedAt",
|
|
||||||
"entity"."localDateTime" AS "entity_localDateTime",
|
|
||||||
"entity"."fileModifiedAt" AS "entity_fileModifiedAt",
|
|
||||||
"entity"."isFavorite" AS "entity_isFavorite",
|
|
||||||
"entity"."isArchived" AS "entity_isArchived",
|
|
||||||
"entity"."isExternal" AS "entity_isExternal",
|
|
||||||
"entity"."isReadOnly" AS "entity_isReadOnly",
|
|
||||||
"entity"."isOffline" AS "entity_isOffline",
|
|
||||||
"entity"."checksum" AS "entity_checksum",
|
|
||||||
"entity"."duration" AS "entity_duration",
|
|
||||||
"entity"."isVisible" AS "entity_isVisible",
|
|
||||||
"entity"."livePhotoVideoId" AS "entity_livePhotoVideoId",
|
|
||||||
"entity"."originalFileName" AS "entity_originalFileName",
|
|
||||||
"entity"."sidecarPath" AS "entity_sidecarPath",
|
|
||||||
"entity"."stackId" AS "entity_stackId",
|
|
||||||
"exifInfo"."assetId" AS "exifInfo_assetId",
|
|
||||||
"exifInfo"."description" AS "exifInfo_description",
|
|
||||||
"exifInfo"."exifImageWidth" AS "exifInfo_exifImageWidth",
|
|
||||||
"exifInfo"."exifImageHeight" AS "exifInfo_exifImageHeight",
|
|
||||||
"exifInfo"."fileSizeInByte" AS "exifInfo_fileSizeInByte",
|
|
||||||
"exifInfo"."orientation" AS "exifInfo_orientation",
|
|
||||||
"exifInfo"."dateTimeOriginal" AS "exifInfo_dateTimeOriginal",
|
|
||||||
"exifInfo"."modifyDate" AS "exifInfo_modifyDate",
|
|
||||||
"exifInfo"."timeZone" AS "exifInfo_timeZone",
|
|
||||||
"exifInfo"."latitude" AS "exifInfo_latitude",
|
|
||||||
"exifInfo"."longitude" AS "exifInfo_longitude",
|
|
||||||
"exifInfo"."projectionType" AS "exifInfo_projectionType",
|
|
||||||
"exifInfo"."city" AS "exifInfo_city",
|
|
||||||
"exifInfo"."livePhotoCID" AS "exifInfo_livePhotoCID",
|
|
||||||
"exifInfo"."autoStackId" AS "exifInfo_autoStackId",
|
|
||||||
"exifInfo"."state" AS "exifInfo_state",
|
|
||||||
"exifInfo"."country" AS "exifInfo_country",
|
|
||||||
"exifInfo"."make" AS "exifInfo_make",
|
|
||||||
"exifInfo"."model" AS "exifInfo_model",
|
|
||||||
"exifInfo"."lensModel" AS "exifInfo_lensModel",
|
|
||||||
"exifInfo"."fNumber" AS "exifInfo_fNumber",
|
|
||||||
"exifInfo"."focalLength" AS "exifInfo_focalLength",
|
|
||||||
"exifInfo"."iso" AS "exifInfo_iso",
|
|
||||||
"exifInfo"."exposureTime" AS "exifInfo_exposureTime",
|
|
||||||
"exifInfo"."profileDescription" AS "exifInfo_profileDescription",
|
|
||||||
"exifInfo"."colorspace" AS "exifInfo_colorspace",
|
|
||||||
"exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample",
|
|
||||||
"exifInfo"."fps" AS "exifInfo_fps"
|
|
||||||
FROM
|
|
||||||
"assets" "entity"
|
|
||||||
LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "entity"."id"
|
|
||||||
WHERE
|
|
||||||
(
|
|
||||||
"entity"."ownerId" = $1
|
|
||||||
AND "entity"."isVisible" = true
|
|
||||||
AND "entity"."isArchived" = false
|
|
||||||
AND "entity"."resizePath" IS NOT NULL
|
|
||||||
AND EXTRACT(
|
|
||||||
DAY
|
|
||||||
FROM
|
|
||||||
"entity"."localDateTime" AT TIME ZONE 'UTC'
|
|
||||||
) = $2
|
|
||||||
AND EXTRACT(
|
|
||||||
MONTH
|
|
||||||
FROM
|
|
||||||
"entity"."localDateTime" AT TIME ZONE 'UTC'
|
|
||||||
) = $3
|
|
||||||
)
|
|
||||||
AND ("entity"."deletedAt" IS NULL)
|
|
||||||
ORDER BY
|
|
||||||
"entity"."localDateTime" DESC
|
|
||||||
|
|
||||||
-- AssetRepository.getByIds
|
-- AssetRepository.getByIds
|
||||||
SELECT
|
SELECT
|
||||||
"AssetEntity"."id" AS "AssetEntity_id",
|
"AssetEntity"."id" AS "AssetEntity_id",
|
||||||
|
@ -42,7 +42,6 @@ export const newAccessRepositoryMock = (reset = true): IAccessRepositoryMock =>
|
|||||||
|
|
||||||
library: {
|
library: {
|
||||||
checkOwnerAccess: jest.fn().mockResolvedValue(new Set()),
|
checkOwnerAccess: jest.fn().mockResolvedValue(new Set()),
|
||||||
checkPartnerAccess: jest.fn().mockResolvedValue(new Set()),
|
|
||||||
},
|
},
|
||||||
|
|
||||||
timeline: {
|
timeline: {
|
||||||
|
1
web/src/lib/assets/immich-logo.json
Normal file
1
web/src/lib/assets/immich-logo.json
Normal file
File diff suppressed because one or more lines are too long
@ -2,11 +2,11 @@
|
|||||||
import { serveFile, type AssetResponseDto } from '@immich/sdk';
|
import { serveFile, type AssetResponseDto } from '@immich/sdk';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
||||||
|
import { getKey } from '$lib/utils';
|
||||||
export let asset: AssetResponseDto;
|
export let asset: AssetResponseDto;
|
||||||
|
|
||||||
const loadAssetData = async () => {
|
const loadAssetData = async () => {
|
||||||
const data = await serveFile({ id: asset.id, isWeb: false, isThumb: false });
|
const data = await serveFile({ id: asset.id, isWeb: false, isThumb: false, key: getKey() });
|
||||||
return URL.createObjectURL(data);
|
return URL.createObjectURL(data);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
@ -14,7 +14,7 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="absolute top-2 left-2 transition-transform {isFocused ? 'translate-y-0' : '-translate-y-10 sr-only'}">
|
<div class="absolute z-50 top-2 left-2 transition-transform {isFocused ? 'translate-y-0' : '-translate-y-10 sr-only'}">
|
||||||
<Button
|
<Button
|
||||||
size={'sm'}
|
size={'sm'}
|
||||||
rounded={false}
|
rounded={false}
|
||||||
|
@ -20,7 +20,7 @@
|
|||||||
|
|
||||||
const dispatch = createEventDispatcher<{
|
const dispatch = createEventDispatcher<{
|
||||||
cancel: void;
|
cancel: void;
|
||||||
submit: { ownerId: string | null };
|
submit: { ownerId: string };
|
||||||
delete: void;
|
delete: void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
@ -83,8 +83,13 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col w-full mt-2">
|
<div class="flex flex-col w-full mt-2">
|
||||||
<label for="timezone">Timezone</label>
|
<Combobox
|
||||||
<Combobox bind:selectedOption id="timezone" options={timezones} placeholder="Search timezone..." />
|
bind:selectedOption
|
||||||
|
id="settings-timezone"
|
||||||
|
label="Timezone"
|
||||||
|
options={timezones}
|
||||||
|
placeholder="Search timezone..."
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ConfirmDialogue>
|
</ConfirmDialogue>
|
||||||
|
@ -11,48 +11,93 @@
|
|||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
|
|
||||||
import Icon from '$lib/components/elements/icon.svelte';
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
import { clickOutside } from '$lib/utils/click-outside';
|
|
||||||
import { mdiMagnify, mdiUnfoldMoreHorizontal, mdiClose } from '@mdi/js';
|
import { mdiMagnify, mdiUnfoldMoreHorizontal, mdiClose } from '@mdi/js';
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher, tick } from 'svelte';
|
||||||
import IconButton from '../elements/buttons/icon-button.svelte';
|
import IconButton from '../elements/buttons/icon-button.svelte';
|
||||||
|
import type { FormEventHandler } from 'svelte/elements';
|
||||||
|
import { shortcuts } from '$lib/utils/shortcut';
|
||||||
|
import { clickOutside } from '$lib/utils/click-outside';
|
||||||
|
|
||||||
export let id: string | undefined = undefined;
|
/**
|
||||||
|
* Unique identifier for the combobox.
|
||||||
|
*/
|
||||||
|
export let id: string;
|
||||||
|
export let label: string;
|
||||||
|
export let hideLabel = false;
|
||||||
export let options: ComboBoxOption[] = [];
|
export let options: ComboBoxOption[] = [];
|
||||||
export let selectedOption: ComboBoxOption | undefined;
|
export let selectedOption: ComboBoxOption | undefined;
|
||||||
export let placeholder = '';
|
export let placeholder = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates whether or not the dropdown autocomplete list should be visible.
|
||||||
|
*/
|
||||||
let isOpen = false;
|
let isOpen = false;
|
||||||
let inputFocused = false;
|
/**
|
||||||
|
* Keeps track of whether the combobox is actively being used.
|
||||||
|
*/
|
||||||
|
let isActive = false;
|
||||||
let searchQuery = selectedOption?.label || '';
|
let searchQuery = selectedOption?.label || '';
|
||||||
|
let selectedIndex: number | undefined;
|
||||||
|
let optionRefs: HTMLElement[] = [];
|
||||||
|
const inputId = `combobox-${id}`;
|
||||||
|
const listboxId = `listbox-${id}`;
|
||||||
|
|
||||||
$: filteredOptions = options.filter((option) => option.label.toLowerCase().includes(searchQuery.toLowerCase()));
|
$: filteredOptions = options.filter((option) => option.label.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||||
|
|
||||||
|
$: {
|
||||||
|
searchQuery = selectedOption ? selectedOption.label : '';
|
||||||
|
}
|
||||||
|
|
||||||
const dispatch = createEventDispatcher<{
|
const dispatch = createEventDispatcher<{
|
||||||
select: ComboBoxOption | undefined;
|
select: ComboBoxOption | undefined;
|
||||||
click: void;
|
click: void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const handleClick = () => {
|
const activate = () => {
|
||||||
searchQuery = '';
|
isActive = true;
|
||||||
|
openDropdown();
|
||||||
|
};
|
||||||
|
|
||||||
|
const deactivate = () => {
|
||||||
|
searchQuery = selectedOption ? selectedOption.label : '';
|
||||||
|
isActive = false;
|
||||||
|
closeDropdown();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDropdown = () => {
|
||||||
isOpen = true;
|
isOpen = true;
|
||||||
inputFocused = true;
|
|
||||||
dispatch('click');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let handleOutClick = () => {
|
const closeDropdown = () => {
|
||||||
// In rare cases it's possible for the input to still have focus and
|
|
||||||
// outclick to fire.
|
|
||||||
if (!inputFocused) {
|
|
||||||
isOpen = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let handleSelect = (option: ComboBoxOption) => {
|
|
||||||
selectedOption = option;
|
|
||||||
dispatch('select', option);
|
|
||||||
isOpen = false;
|
isOpen = false;
|
||||||
|
selectedIndex = undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const incrementSelectedIndex = async (increment: number) => {
|
||||||
|
if (filteredOptions.length === 0) {
|
||||||
|
selectedIndex = 0;
|
||||||
|
} else if (selectedIndex === undefined) {
|
||||||
|
selectedIndex = increment === 1 ? 0 : filteredOptions.length - 1;
|
||||||
|
} else {
|
||||||
|
selectedIndex = (selectedIndex + increment + filteredOptions.length) % filteredOptions.length;
|
||||||
|
}
|
||||||
|
await tick();
|
||||||
|
optionRefs[selectedIndex]?.scrollIntoView({ block: 'nearest' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onInput: FormEventHandler<HTMLInputElement> = (event) => {
|
||||||
|
openDropdown();
|
||||||
|
searchQuery = event.currentTarget.value;
|
||||||
|
selectedIndex = undefined;
|
||||||
|
optionRefs[0]?.scrollIntoView({ block: 'nearest' });
|
||||||
|
};
|
||||||
|
|
||||||
|
let onSelect = (option: ComboBoxOption) => {
|
||||||
|
selectedOption = option;
|
||||||
|
searchQuery = option.label;
|
||||||
|
dispatch('select', option);
|
||||||
|
closeDropdown();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onClear = () => {
|
const onClear = () => {
|
||||||
@ -62,30 +107,80 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="relative w-full dark:text-gray-300 text-gray-700 text-base" use:clickOutside on:outclick={handleOutClick}>
|
<label class="text-sm text-black dark:text-white" class:sr-only={hideLabel} for={inputId}>{label}</label>
|
||||||
|
<div
|
||||||
|
class="relative w-full dark:text-gray-300 text-gray-700 text-base"
|
||||||
|
use:clickOutside={{ onOutclick: deactivate }}
|
||||||
|
on:focusout={(e) => {
|
||||||
|
if (e.relatedTarget instanceof Node && !e.currentTarget.contains(e.relatedTarget)) {
|
||||||
|
deactivate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
{#if isOpen}
|
{#if isActive}
|
||||||
<div class="absolute inset-y-0 left-0 flex items-center pl-3">
|
<div class="absolute inset-y-0 left-0 flex items-center pl-3">
|
||||||
<div class="dark:text-immich-dark-fg/75">
|
<div class="dark:text-immich-dark-fg/75">
|
||||||
<Icon path={mdiMagnify} />
|
<Icon path={mdiMagnify} ariaHidden={true} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<input
|
<input
|
||||||
{id}
|
|
||||||
{placeholder}
|
{placeholder}
|
||||||
role="combobox"
|
aria-activedescendant={selectedIndex || selectedIndex === 0 ? `${listboxId}-${selectedIndex}` : ''}
|
||||||
|
aria-autocomplete="list"
|
||||||
|
aria-controls={listboxId}
|
||||||
aria-expanded={isOpen}
|
aria-expanded={isOpen}
|
||||||
aria-controls={id}
|
autocomplete="off"
|
||||||
class="immich-form-input text-sm text-left w-full !pr-12 transition-all"
|
class:!pl-8={isActive}
|
||||||
class:!pl-8={isOpen}
|
|
||||||
class:!rounded-b-none={isOpen}
|
class:!rounded-b-none={isOpen}
|
||||||
class:cursor-pointer={!isOpen}
|
class:cursor-pointer={!isActive}
|
||||||
value={isOpen ? '' : selectedOption?.label || ''}
|
class="immich-form-input text-sm text-left w-full !pr-12 transition-all"
|
||||||
on:input={(e) => (searchQuery = e.currentTarget.value)}
|
id={inputId}
|
||||||
on:focus={handleClick}
|
on:click={activate}
|
||||||
on:blur={() => (inputFocused = false)}
|
on:focus={activate}
|
||||||
|
on:input={onInput}
|
||||||
|
role="combobox"
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
use:shortcuts={[
|
||||||
|
{
|
||||||
|
shortcut: { key: 'ArrowUp' },
|
||||||
|
onShortcut: () => {
|
||||||
|
openDropdown();
|
||||||
|
void incrementSelectedIndex(-1);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
shortcut: { key: 'ArrowDown' },
|
||||||
|
onShortcut: () => {
|
||||||
|
openDropdown();
|
||||||
|
void incrementSelectedIndex(1);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
shortcut: { key: 'ArrowDown', alt: true },
|
||||||
|
onShortcut: () => {
|
||||||
|
openDropdown();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
shortcut: { key: 'Enter' },
|
||||||
|
onShortcut: () => {
|
||||||
|
if (selectedIndex !== undefined && filteredOptions.length > 0) {
|
||||||
|
onSelect(filteredOptions[selectedIndex]);
|
||||||
|
}
|
||||||
|
closeDropdown();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
shortcut: { key: 'Escape' },
|
||||||
|
onShortcut: () => {
|
||||||
|
closeDropdown();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@ -95,37 +190,51 @@
|
|||||||
>
|
>
|
||||||
{#if selectedOption}
|
{#if selectedOption}
|
||||||
<IconButton color="transparent-gray" on:click={onClear} title="Clear value">
|
<IconButton color="transparent-gray" on:click={onClear} title="Clear value">
|
||||||
<Icon path={mdiClose} />
|
<Icon path={mdiClose} ariaLabel="Clear value" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
{:else if !isOpen}
|
{:else if !isOpen}
|
||||||
<Icon path={mdiUnfoldMoreHorizontal} />
|
<Icon path={mdiUnfoldMoreHorizontal} ariaHidden={true} />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if isOpen}
|
<ul
|
||||||
<div
|
role="listbox"
|
||||||
role="listbox"
|
id={listboxId}
|
||||||
transition:fly={{ duration: 250 }}
|
transition:fly={{ duration: 250 }}
|
||||||
class="absolute text-left text-sm w-full max-h-64 overflow-y-auto bg-white dark:bg-gray-800 rounded-b-lg border border-t-0 border-gray-300 dark:border-gray-900 z-10"
|
class="absolute text-left text-sm w-full max-h-64 overflow-y-auto bg-white dark:bg-gray-800 border-t-0 border-gray-300 dark:border-gray-900 rounded-b-xl z-10"
|
||||||
>
|
class:border={isOpen}
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
{#if isOpen}
|
||||||
{#if filteredOptions.length === 0}
|
{#if filteredOptions.length === 0}
|
||||||
<div class="px-4 py-2 font-medium">No results</div>
|
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||||
{/if}
|
<li
|
||||||
{#each filteredOptions as option (option.label)}
|
role="option"
|
||||||
{@const selected = option.label === selectedOption?.label}
|
aria-selected={selectedIndex === 0}
|
||||||
<button
|
aria-disabled={true}
|
||||||
type="button"
|
class:bg-gray-100={selectedIndex === 0}
|
||||||
|
class:dark:bg-gray-700={selectedIndex === 0}
|
||||||
|
class="text-left w-full px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-default"
|
||||||
|
id={`${listboxId}-${0}`}
|
||||||
|
on:click={() => closeDropdown()}
|
||||||
|
>
|
||||||
|
No results
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
|
{#each filteredOptions as option, index (option.label)}
|
||||||
|
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||||
|
<li
|
||||||
|
aria-selected={index === selectedIndex}
|
||||||
|
bind:this={optionRefs[index]}
|
||||||
|
class="text-left w-full px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all cursor-pointer aria-selected:bg-gray-100 aria-selected:dark:bg-gray-700"
|
||||||
|
id={`${listboxId}-${index}`}
|
||||||
|
on:click={() => onSelect(option)}
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={selected}
|
|
||||||
class="text-left w-full px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all"
|
|
||||||
class:bg-gray-300={selected}
|
|
||||||
class:dark:bg-gray-600={selected}
|
|
||||||
on:click={() => handleSelect(option)}
|
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
</button>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
{/if}
|
||||||
{/if}
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
@ -2,8 +2,10 @@
|
|||||||
import logoDarkUrl from '$lib/assets/immich-logo-inline-dark.svg';
|
import logoDarkUrl from '$lib/assets/immich-logo-inline-dark.svg';
|
||||||
import logoLightUrl from '$lib/assets/immich-logo-inline-light.svg';
|
import logoLightUrl from '$lib/assets/immich-logo-inline-light.svg';
|
||||||
import logoNoText from '$lib/assets/immich-logo.svg';
|
import logoNoText from '$lib/assets/immich-logo.svg';
|
||||||
|
import { content as alternativeLogo } from '$lib/assets/immich-logo.json';
|
||||||
import { Theme } from '$lib/constants';
|
import { Theme } from '$lib/constants';
|
||||||
import { colorTheme } from '$lib/stores/preferences.store';
|
import { colorTheme } from '$lib/stores/preferences.store';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
import type { HTMLImgAttributes } from 'svelte/elements';
|
import type { HTMLImgAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
@ -14,11 +16,17 @@
|
|||||||
|
|
||||||
export let noText = false;
|
export let noText = false;
|
||||||
export let draggable = false;
|
export let draggable = false;
|
||||||
|
|
||||||
|
const today = DateTime.now().toLocal();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<img
|
{#if today.month === 4 && today.day === 1}
|
||||||
src={noText ? logoNoText : $colorTheme.value == Theme.LIGHT ? logoLightUrl : logoDarkUrl}
|
<img src="data:image/png;base64, {alternativeLogo}" alt="Immich Logo" class="h-20" {draggable} />
|
||||||
alt="Immich Logo"
|
{:else}
|
||||||
{draggable}
|
<img
|
||||||
{...$$restProps}
|
src={noText ? logoNoText : $colorTheme.value == Theme.LIGHT ? logoLightUrl : logoDarkUrl}
|
||||||
/>
|
alt="Immich Logo"
|
||||||
|
{draggable}
|
||||||
|
{...$$restProps}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
@ -11,6 +11,7 @@
|
|||||||
import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk';
|
import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk';
|
||||||
import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
|
import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
|
||||||
import { handlePromiseError } from '$lib/utils';
|
import { handlePromiseError } from '$lib/utils';
|
||||||
|
import { shortcut } from '$lib/utils/shortcut';
|
||||||
|
|
||||||
export let value = '';
|
export let value = '';
|
||||||
export let grayTheme: boolean;
|
export let grayTheme: boolean;
|
||||||
@ -84,7 +85,16 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="w-full relative" use:clickOutside on:outclick={onFocusOut} on:escape={onFocusOut}>
|
<svelte:window
|
||||||
|
use:shortcut={{
|
||||||
|
shortcut: { key: 'Escape' },
|
||||||
|
onShortcut: () => {
|
||||||
|
onFocusOut();
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="w-full relative" use:clickOutside={{ onOutclick: onFocusOut }}>
|
||||||
<form
|
<form
|
||||||
draggable="false"
|
draggable="false"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
@ -118,6 +128,12 @@
|
|||||||
bind:this={input}
|
bind:this={input}
|
||||||
on:click={onFocusIn}
|
on:click={onFocusIn}
|
||||||
disabled={showFilter}
|
disabled={showFilter}
|
||||||
|
use:shortcut={{
|
||||||
|
shortcut: { key: 'Escape' },
|
||||||
|
onShortcut: () => {
|
||||||
|
onFocusOut();
|
||||||
|
},
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="absolute inset-y-0 {showClearIcon ? 'right-14' : 'right-5'} flex items-center pl-6 transition-all">
|
<div class="absolute inset-y-0 {showClearIcon ? 'right-14' : 'right-5'} flex items-center pl-6 transition-all">
|
||||||
|
@ -40,24 +40,24 @@
|
|||||||
|
|
||||||
<div class="grid grid-cols-[repeat(auto-fit,minmax(10rem,1fr))] gap-5 mt-1">
|
<div class="grid grid-cols-[repeat(auto-fit,minmax(10rem,1fr))] gap-5 mt-1">
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<label class="text-sm text-black dark:text-white" for="search-camera-make">Make</label>
|
|
||||||
<Combobox
|
<Combobox
|
||||||
id="search-camera-make"
|
id="camera-make"
|
||||||
options={toComboBoxOptions(makes)}
|
label="Make"
|
||||||
selectedOption={makeFilter ? { label: makeFilter, value: makeFilter } : undefined}
|
|
||||||
on:select={({ detail }) => (filters.make = detail?.value)}
|
on:select={({ detail }) => (filters.make = detail?.value)}
|
||||||
|
options={toComboBoxOptions(makes)}
|
||||||
placeholder="Search camera make..."
|
placeholder="Search camera make..."
|
||||||
|
selectedOption={makeFilter ? { label: makeFilter, value: makeFilter } : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<label class="text-sm text-black dark:text-white" for="search-camera-model">Model</label>
|
|
||||||
<Combobox
|
<Combobox
|
||||||
id="search-camera-model"
|
id="camera-model"
|
||||||
options={toComboBoxOptions(models)}
|
label="Model"
|
||||||
selectedOption={modelFilter ? { label: modelFilter, value: modelFilter } : undefined}
|
|
||||||
on:select={({ detail }) => (filters.model = detail?.value)}
|
on:select={({ detail }) => (filters.model = detail?.value)}
|
||||||
|
options={toComboBoxOptions(models)}
|
||||||
placeholder="Search camera model..."
|
placeholder="Search camera model..."
|
||||||
|
selectedOption={modelFilter ? { label: modelFilter, value: modelFilter } : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -62,35 +62,35 @@
|
|||||||
|
|
||||||
<div class="grid grid-cols-[repeat(auto-fit,minmax(10rem,1fr))] gap-5 mt-1">
|
<div class="grid grid-cols-[repeat(auto-fit,minmax(10rem,1fr))] gap-5 mt-1">
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<label class="text-sm text-black dark:text-white" for="search-place-country">Country</label>
|
|
||||||
<Combobox
|
<Combobox
|
||||||
id="search-place-country"
|
id="location-country"
|
||||||
options={toComboBoxOptions(countries)}
|
label="Country"
|
||||||
selectedOption={filters.country ? { label: filters.country, value: filters.country } : undefined}
|
|
||||||
on:select={({ detail }) => (filters.country = detail?.value)}
|
on:select={({ detail }) => (filters.country = detail?.value)}
|
||||||
|
options={toComboBoxOptions(countries)}
|
||||||
placeholder="Search country..."
|
placeholder="Search country..."
|
||||||
|
selectedOption={filters.country ? { label: filters.country, value: filters.country } : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<label class="text-sm text-black dark:text-white" for="search-place-state">State</label>
|
|
||||||
<Combobox
|
<Combobox
|
||||||
id="search-place-state"
|
id="location-state"
|
||||||
options={toComboBoxOptions(states)}
|
label="State"
|
||||||
selectedOption={filters.state ? { label: filters.state, value: filters.state } : undefined}
|
|
||||||
on:select={({ detail }) => (filters.state = detail?.value)}
|
on:select={({ detail }) => (filters.state = detail?.value)}
|
||||||
|
options={toComboBoxOptions(states)}
|
||||||
placeholder="Search state..."
|
placeholder="Search state..."
|
||||||
|
selectedOption={filters.state ? { label: filters.state, value: filters.state } : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<label class="text-sm text-black dark:text-white" for="search-place-city">City</label>
|
|
||||||
<Combobox
|
<Combobox
|
||||||
id="search-place-city"
|
id="location-city"
|
||||||
options={toComboBoxOptions(cities)}
|
label="City"
|
||||||
selectedOption={filters.city ? { label: filters.city, value: filters.city } : undefined}
|
|
||||||
on:select={({ detail }) => (filters.city = detail?.value)}
|
on:select={({ detail }) => (filters.city = detail?.value)}
|
||||||
|
options={toComboBoxOptions(cities)}
|
||||||
placeholder="Search city..."
|
placeholder="Search city..."
|
||||||
|
selectedOption={filters.city ? { label: filters.city, value: filters.city } : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte';
|
import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte';
|
||||||
|
|
||||||
|
export let id: string;
|
||||||
export let title: string;
|
export let title: string;
|
||||||
export let comboboxPlaceholder: string;
|
export let comboboxPlaceholder: string;
|
||||||
export let subtitle = '';
|
export let subtitle = '';
|
||||||
@ -32,6 +33,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<Combobox
|
<Combobox
|
||||||
|
{id}
|
||||||
|
label={title}
|
||||||
|
hideLabel={true}
|
||||||
{selectedOption}
|
{selectedOption}
|
||||||
{options}
|
{options}
|
||||||
placeholder={comboboxPlaceholder}
|
placeholder={comboboxPlaceholder}
|
||||||
|
@ -86,6 +86,7 @@
|
|||||||
{#if $locale !== undefined}
|
{#if $locale !== undefined}
|
||||||
<div class="ml-4">
|
<div class="ml-4">
|
||||||
<SettingCombobox
|
<SettingCombobox
|
||||||
|
id="custom-locale"
|
||||||
comboboxPlaceholder="Searching locales..."
|
comboboxPlaceholder="Searching locales..."
|
||||||
{selectedOption}
|
{selectedOption}
|
||||||
options={getAllLanguages()}
|
options={getAllLanguages()}
|
||||||
|
@ -28,7 +28,6 @@
|
|||||||
removeOfflineFiles,
|
removeOfflineFiles,
|
||||||
scanLibrary,
|
scanLibrary,
|
||||||
updateLibrary,
|
updateLibrary,
|
||||||
type CreateLibraryDto,
|
|
||||||
type LibraryResponseDto,
|
type LibraryResponseDto,
|
||||||
type LibraryStatsResponseDto,
|
type LibraryStatsResponseDto,
|
||||||
type UserResponseDto,
|
type UserResponseDto,
|
||||||
@ -117,14 +116,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCreate = async (ownerId: string | null) => {
|
const handleCreate = async (ownerId: string) => {
|
||||||
try {
|
try {
|
||||||
let createLibraryDto: CreateLibraryDto = { type: LibraryType.External };
|
const createdLibrary = await createLibrary({ createLibraryDto: { ownerId, type: LibraryType.External } });
|
||||||
if (ownerId) {
|
|
||||||
createLibraryDto = { ...createLibraryDto, ownerId };
|
|
||||||
}
|
|
||||||
|
|
||||||
const createdLibrary = await createLibrary({ createLibraryDto });
|
|
||||||
|
|
||||||
notificationController.show({
|
notificationController.show({
|
||||||
message: `Created library: ${createdLibrary.name}`,
|
message: `Created library: ${createdLibrary.name}`,
|
||||||
|
@ -286,7 +286,7 @@
|
|||||||
<tr class="flex w-full place-items-center p-2 md:p-5">
|
<tr class="flex w-full place-items-center p-2 md:p-5">
|
||||||
<th class="w-full text-sm font-medium place-items-center flex justify-between" colspan="2">
|
<th class="w-full text-sm font-medium place-items-center flex justify-between" colspan="2">
|
||||||
<div class="px-3">
|
<div class="px-3">
|
||||||
<p>UNTRACKS FILES {extras.length > 0 ? `(${extras.length})` : ''}</p>
|
<p>UNTRACKED FILES {extras.length > 0 ? `(${extras.length})` : ''}</p>
|
||||||
<p class="text-gray-600 dark:text-gray-300 mt-1">
|
<p class="text-gray-600 dark:text-gray-300 mt-1">
|
||||||
These files are not tracked by the application. They can be the results of failed moves,
|
These files are not tracked by the application. They can be the results of failed moves,
|
||||||
interrupted uploads, or left behind due to a bug
|
interrupted uploads, or left behind due to a bug
|
||||||
|
Loading…
x
Reference in New Issue
Block a user