merge main

This commit is contained in:
martabal 2023-11-04 15:21:33 +01:00
commit fb9b854bf1
No known key found for this signature in database
GPG key ID: C00196E3148A52BD
53 changed files with 978 additions and 398 deletions

View file

@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.83.0
* The version of the OpenAPI document: 1.84.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View file

@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.83.0
* The version of the OpenAPI document: 1.84.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View file

@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.83.0
* The version of the OpenAPI document: 1.84.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View file

@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.83.0
* The version of the OpenAPI document: 1.84.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View file

@ -33,8 +33,6 @@ To be concise, Immich can now read in the gallery files, register the path into
- Only new files that are added to the gallery will be detected.
- Deleted and moved files will not be detected.
You can find more information on how to use the feature by reading the documentation [here](/docs/features/read-only-gallery).
## Memory feature
This is considered a fun feature that the team and I wanted to build for so long, but we had to put it off because of the refactoring of the code base. The code base is now in a good enough form to circle back and add more exciting features.

View file

@ -17,13 +17,13 @@ docker exec -t immich_postgres pg_dumpall -c -U postgres | gzip > "/path/to/back
```
```bash title='Restore'
docker-compose down -v # CAUTION! Deletes all Immich data to start from scratch.
docker-compose pull # Update to latest version of Immich (if desired)
docker-compose create # Create Docker containers for Immich apps without running them.
docker compose down -v # CAUTION! Deletes all Immich data to start from scratch.
docker compose pull # Update to latest version of Immich (if desired)
docker compose create # Create Docker containers for Immich apps without running them.
docker start immich_postgres # Start Postgres server
sleep 10 # Wait for Postgres server to start up
gunzip < "/path/to/backup/dump.sql.gz" | docker exec -i immich_postgres psql -U postgres -d immich # Restore Backup
docker-compose up -d # Start remainder of Immich apps
docker compose up -d # Start remainder of Immich apps
```
Note that for the database restore to proceed properly, it requires a completely fresh install (i.e. the Immich server has never run since creating the Docker containers). If the Immich app has run, Postgres conflicts may be encountered upon database restoration (relation already exists, violated foreign key constraints, multiple primary keys, etc.).

View file

@ -32,7 +32,6 @@ immich
| --server / -s | Immich's server address |
| --threads / -t | Number of threads to use (Default 5) |
| --album/ -al | Create albums for assets based on the parent folder or a given name |
| --import/ -i | Import gallery (assets are not uploaded) |
## Quick Start
@ -108,70 +107,3 @@ npm run build
```bash title="Run the command"
node bin/index.js upload --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api --recursive your/asset/directory
```
---
## Importing existing libraries
If you do not wish to upload files into the server, existing files can be imported into the immich gallery through the use of the `--import` flag.
```
immich upload --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api --recursive directory/ --import
```
```
immich upload --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api file1.jpg file2.jpg --import
```
The `immich-server` and `immich-microservices` containers must be able to access the files, or directories at the path referenced in the command. The directories referenced must be set under a user's `External Path` setting. More detailed instructions can be found [here](/docs/features/read-only-gallery).
:::tip Matching volume references
The import command is most easily run on the machine running the immich service, as the path to the files on the machine running the command and the server much match identically.
If you are running immich within docker, the volume pointing to your existing library should be identical with your host machine.
```diff title="docker-compose.yml"
immich-server:
container_name: immich_server
image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}
command: [ "start.sh", "immich" ]
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
+ - /path/to/media:/path/to/media
env_file:
- .env
depends_on:
- redis
- database
- typesense
restart: always
immich-microservices:
container_name: immich_microservices
image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}
command: [ "start.sh", "microservices" ]
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
+ - /path/to/media:/path/to/media
env_file:
- .env
depends_on:
- redis
- database
- typesense
restart: always
```
The proper command for above would be as shown below. You should have access to `/path/to/media` exactly on the environment the CLI command is being run on
```
immich upload --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api --recursive /path/to/media --import
```
If you are running the import using the docker command, please note that the volumes should point to the `/path/to/media` exactly on the environment the CLI command is being run on
```
docker run -it --rm -v "/path/to/media:/path/to/media" ghcr.io/immich-app/immich-cli:latest upload --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api --recursive /path/to/media --import
```
:::

View file

@ -1,97 +0,0 @@
# Read-only Gallery [Deprecated]
:::caution
This feature is being deprecated in favor of [Libraries](/docs/features/libraries.md).
:::
## Overview
This feature enables users to use an existing gallery without uploading the assets to Immich.
Upon syncing the file information, it will be read by Immich to generate supported files.
## Usage
:::tip Example scenario
On the VM/system that Immich is running, I have 2 galleries that I want to use with Immich.
- My gallery is stored at `/mnt/media/precious-memory`
- My wife's gallery is stored at `/mnt/media/childhood-memory`
We will use those values in the steps below.
:::
### Mount the gallery to the containers.
`immich-server` and `immich-microservices` containers will need access to the gallery. Mount the directory path as in the example below
```diff title="docker-compose.yml"
immich-server:
container_name: immich_server
image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}
command: [ "start.sh", "immich" ]
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
+ - /mnt/media/precious-memory:/mnt/media/precious-memory:ro
+ - /mnt/media/childhood-memory:/mnt/media/childhood-memory:ro
env_file:
- .env
depends_on:
- redis
- database
- typesense
restart: always
immich-microservices:
container_name: immich_microservices
image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}
command: [ "start.sh", "microservices" ]
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
+ - /mnt/media/precious-memory:/mnt/media/precious-memory:ro
+ - /mnt/media/childhood-memory:/mnt/media/childhood-memory:ro
env_file:
- .env
depends_on:
- redis
- database
- typesense
restart: always
```
:::tip
Internal and external path have to be identical.
:::
_Remember to bring the container down/up to register the changes. Make sure you can see the mounted path in the container._
### Register the path for the user.
This action is done by the admin of the instance.
- Navigate to `Administration > Users` page on the web.
- Click on the user edit button.
- Add the gallery path to the `External Path` field for the corresponding user and confirm the changes.
<img src={require('./img/me.png').default} width='33%' title='My Account Storage Path' />
<img src={require('./img/my-wife.png').default} width='33%' title='My Wifes Account Storage Path' />
### Sync with the CLI tool.
- Install or update the [CLI Tool](/docs/features/bulk-upload.md). The import feature is supported from version `v0.39.0` of the CLI
- Run the command below to sync the gallery with Immich.
```bash title="Import my gallery"
immich upload --key <my-api-key> --server http://my-server-ip:2283/api /mnt/media/precious-memory --recursive --import
```
```bash title="Import my wife gallery"
immich upload --key <my-wife-api-key> --server http://my-server-ip:2283/api /mnt/media/childhood-memory --recursive --import
```
The `--import` flag will tell Immich to import the files by path instead of uploading them.

View file

@ -1,6 +1,6 @@
[tool.poetry]
name = "machine-learning"
version = "1.83.0"
version = "1.84.0"
description = ""
authors = ["Hau Tran <alex.tran1502@gmail.com>"]
readme = "README.md"

View file

@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 107,
"android.injected.version.name" => "1.83.0",
"android.injected.version.code" => 108,
"android.injected.version.name" => "1.84.0",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

View file

@ -5,17 +5,17 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000269">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000625">
</testcase>
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="81.160108">
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="70.943413">
</testcase>
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="39.176668">
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="30.374484">
</testcase>

View file

@ -23,6 +23,7 @@
"album_viewer_appbar_share_err_title": "Failed to change album title",
"album_viewer_appbar_share_leave": "Leave album",
"album_viewer_appbar_share_remove": "Remove from album",
"album_viewer_appbar_share_to": "Share To",
"album_viewer_page_share_add_users": "Add users",
"all_people_page_title": "People",
"all_videos_page_title": "Videos",

View file

@ -379,7 +379,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 118;
CURRENT_PROJECT_VERSION = 124;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@ -515,7 +515,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 118;
CURRENT_PROJECT_VERSION = 124;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@ -543,7 +543,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 118;
CURRENT_PROJECT_VERSION = 124;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;

View file

@ -59,11 +59,11 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.78.1</string>
<string>1.84.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>118</string>
<string>124</string>
<key>FLTEnableImpeller</key>
<true />
<key>ITSAppUsesNonExemptEncryption</key>

View file

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

View file

@ -5,32 +5,32 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000256">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000253">
</testcase>
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="7.645306">
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.181977">
</testcase>
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="4.669798">
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="16.12614">
</testcase>
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="2.218788">
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.162663">
</testcase>
<testcase classname="fastlane.lanes" name="4: build_app" time="97.596654">
<testcase classname="fastlane.lanes" name="4: build_app" time="145.399278">
</testcase>
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="89.490906">
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="61.317235">
</testcase>

View file

@ -7,6 +7,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_displaymode/flutter_displaymode.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:timezone/data/latest.dart';
import 'package:immich_mobile/constants/locales.dart';
import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
@ -77,6 +78,8 @@ Future<void> initApp() async {
log.severe('Catch all error: ${error.toString()} - $error', error, stack);
return true;
};
initializeTimeZones();
}
Future<Isar> loadDb() async {

View file

@ -7,6 +7,8 @@ import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart';
import 'package:immich_mobile/modules/album/providers/album_viewer.provider.dart';
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
import 'package:immich_mobile/shared/ui/share_dialog.dart';
import 'package:immich_mobile/shared/services/share.service.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart';
@ -160,40 +162,77 @@ class AlbumViewerAppbar extends HookConsumerWidget
ImmichLoadingOverlayController.appLoader.hide();
}
buildBottomSheetActionButton() {
void handleShareAssets(
WidgetRef ref,
BuildContext context,
Set<Asset> selection,
) {
showDialog(
context: context,
builder: (BuildContext buildContext) {
ref.watch(shareServiceProvider).shareAssets(selection.toList()).then(
(bool status) {
if (!status) {
ImmichToast.show(
context: context,
msg: 'image_viewer_page_state_provider_share_error'.tr(),
toastType: ToastType.error,
gravity: ToastGravity.BOTTOM,
);
}
Navigator.of(buildContext).pop();
},
);
return const ShareDialog();
},
barrierDismissible: false,
);
}
void onShareAssetsTo() async {
ImmichLoadingOverlayController.appLoader.show();
handleShareAssets(ref, context, selected);
ImmichLoadingOverlayController.appLoader.hide();
}
buildBottomSheetActions() {
if (selected.isNotEmpty) {
if (album.ownerId == userId) {
return ListTile(
return [
ListTile(
leading: const Icon(Icons.ios_share_rounded),
title: const Text(
'album_viewer_appbar_share_to',
style: TextStyle(fontWeight: FontWeight.bold),
).tr(),
onTap: () => onShareAssetsTo(),
),
album.ownerId == userId ? ListTile(
leading: const Icon(Icons.delete_sweep_rounded),
title: const Text(
'album_viewer_appbar_share_remove',
style: TextStyle(fontWeight: FontWeight.bold),
).tr(),
onTap: () => onRemoveFromAlbumPressed(),
);
} else {
return const SizedBox();
}
) : const SizedBox(),
];
} else {
if (album.ownerId == userId) {
return ListTile(
return [
album.ownerId == userId ? ListTile(
leading: const Icon(Icons.delete_forever_rounded),
title: const Text(
'album_viewer_appbar_share_delete',
style: TextStyle(fontWeight: FontWeight.bold),
).tr(),
onTap: () => onDeleteAlbumPressed(),
);
} else {
return ListTile(
) : ListTile(
leading: const Icon(Icons.person_remove_rounded),
title: const Text(
'album_viewer_appbar_share_leave',
style: TextStyle(fontWeight: FontWeight.bold),
).tr(),
onTap: () => onLeaveAlbumPressed(),
);
}
),
];
}
}
@ -257,7 +296,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
buildBottomSheetActionButton(),
...buildBottomSheetActions(),
if (selected.isEmpty && onAddPhotos != null) ...commonActions,
if (selected.isEmpty &&
onAddPhotos != null &&

View file

@ -4,6 +4,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:timezone/timezone.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/description_input.dart';
import 'package:immich_mobile/modules/map/ui/map_thumbnail.dart';
import 'package:immich_mobile/shared/models/asset.dart';
@ -26,12 +27,36 @@ class ExifBottomSheet extends HookConsumerWidget {
exifInfo.latitude != 0 &&
exifInfo.longitude != 0;
String get formattedDateTime {
final fileCreatedAt = asset.fileCreatedAt.toLocal();
final date = DateFormat.yMMMEd().format(fileCreatedAt);
final time = DateFormat.jm().format(fileCreatedAt);
String formatTimeZone(Duration d) =>
"GMT${d.isNegative ? '-': '+'}${d.inHours.abs().toString().padLeft(2, '0')}:${d.inMinutes.abs().remainder(60).toString().padLeft(2, '0')}";
return '$date$time';
String get formattedDateTime {
DateTime dt = asset.fileCreatedAt.toLocal();
String? timeZone;
if (asset.exifInfo?.dateTimeOriginal != null) {
dt = asset.exifInfo!.dateTimeOriginal!;
if (asset.exifInfo?.timeZone != null) {
dt = dt.toUtc();
try {
final location = getLocation(asset.exifInfo!.timeZone!);
dt = TZDateTime.from(dt, location);
} on LocationNotFoundException {
RegExp re = RegExp(r'^utc(?:([+-]\d{1,2})(?::(\d{2}))?)?$', caseSensitive: false);
final m = re.firstMatch(asset.exifInfo!.timeZone!);
if (m != null) {
final duration = Duration(hours: int.parse(m.group(1) ?? '0'), minutes: int.parse(m.group(2) ?? '0'));
dt = dt.add(duration);
timeZone = formatTimeZone(duration);
}
}
}
}
final date = DateFormat.yMMMEd().format(dt);
final time = DateFormat.jm().format(dt);
timeZone ??= formatTimeZone(dt.timeZoneOffset);
return '$date$time $timeZone';
}
Future<Uri?> _createCoordinatesUri(ExifInfo? exifInfo) async {

View file

@ -100,7 +100,7 @@ class ControlBottomAppBar extends ConsumerWidget {
label: "control_bottom_app_bar_stack".tr(),
onPressed: enabled ? onStack : null,
),
if (!hasRemote)
if (hasLocal)
ControlBoxButton(
iconData: Icons.backup_outlined,
label: "Upload",

View file

@ -169,9 +169,10 @@ class HomePage extends HookConsumerWidget {
processing.value = true;
selectionEnabledHook.value = false;
try {
ref
.read(manualUploadProvider.notifier)
.uploadAssets(context, selection.value);
ref.read(manualUploadProvider.notifier).uploadAssets(
context,
selection.value.where((a) => a.storage == AssetState.local),
);
} finally {
processing.value = false;
}

View file

@ -8,6 +8,8 @@ part 'exif_info.g.dart';
class ExifInfo {
Id? id;
int? fileSize;
DateTime? dateTimeOriginal;
String? timeZone;
String? make;
String? model;
String? lens;
@ -47,6 +49,8 @@ class ExifInfo {
ExifInfo.fromDto(ExifResponseDto dto)
: fileSize = dto.fileSizeInByte,
dateTimeOriginal = dto.dateTimeOriginal,
timeZone = dto.timeZone,
make = dto.make,
model = dto.model,
lens = dto.lensModel,
@ -64,6 +68,8 @@ class ExifInfo {
ExifInfo({
this.id,
this.fileSize,
this.dateTimeOriginal,
this.timeZone,
this.make,
this.model,
this.lens,
@ -82,6 +88,8 @@ class ExifInfo {
ExifInfo copyWith({
Id? id,
int? fileSize,
DateTime? dateTimeOriginal,
String? timeZone,
String? make,
String? model,
String? lens,
@ -99,6 +107,8 @@ class ExifInfo {
ExifInfo(
id: id ?? this.id,
fileSize: fileSize ?? this.fileSize,
dateTimeOriginal: dateTimeOriginal ?? this.dateTimeOriginal,
timeZone: timeZone ?? this.timeZone,
make: make ?? this.make,
model: model ?? this.model,
lens: lens ?? this.lens,
@ -119,6 +129,8 @@ class ExifInfo {
if (other is! ExifInfo) return false;
return id == other.id &&
fileSize == other.fileSize &&
dateTimeOriginal == other.dateTimeOriginal &&
timeZone == other.timeZone &&
make == other.make &&
model == other.model &&
lens == other.lens &&
@ -139,6 +151,8 @@ class ExifInfo {
int get hashCode =>
id.hashCode ^
fileSize.hashCode ^
dateTimeOriginal.hashCode ^
timeZone.hashCode ^
make.hashCode ^
model.hashCode ^
lens.hashCode ^

View file

@ -27,65 +27,75 @@ const ExifInfoSchema = CollectionSchema(
name: r'country',
type: IsarType.string,
),
r'description': PropertySchema(
r'dateTimeOriginal': PropertySchema(
id: 2,
name: r'dateTimeOriginal',
type: IsarType.dateTime,
),
r'description': PropertySchema(
id: 3,
name: r'description',
type: IsarType.string,
),
r'exposureSeconds': PropertySchema(
id: 3,
id: 4,
name: r'exposureSeconds',
type: IsarType.float,
),
r'f': PropertySchema(
id: 4,
id: 5,
name: r'f',
type: IsarType.float,
),
r'fileSize': PropertySchema(
id: 5,
id: 6,
name: r'fileSize',
type: IsarType.long,
),
r'iso': PropertySchema(
id: 6,
id: 7,
name: r'iso',
type: IsarType.int,
),
r'lat': PropertySchema(
id: 7,
id: 8,
name: r'lat',
type: IsarType.float,
),
r'lens': PropertySchema(
id: 8,
id: 9,
name: r'lens',
type: IsarType.string,
),
r'long': PropertySchema(
id: 9,
id: 10,
name: r'long',
type: IsarType.float,
),
r'make': PropertySchema(
id: 10,
id: 11,
name: r'make',
type: IsarType.string,
),
r'mm': PropertySchema(
id: 11,
id: 12,
name: r'mm',
type: IsarType.float,
),
r'model': PropertySchema(
id: 12,
id: 13,
name: r'model',
type: IsarType.string,
),
r'state': PropertySchema(
id: 13,
id: 14,
name: r'state',
type: IsarType.string,
),
r'timeZone': PropertySchema(
id: 15,
name: r'timeZone',
type: IsarType.string,
)
},
estimateSize: _exifInfoEstimateSize,
@ -150,6 +160,12 @@ int _exifInfoEstimateSize(
bytesCount += 3 + value.length * 3;
}
}
{
final value = object.timeZone;
if (value != null) {
bytesCount += 3 + value.length * 3;
}
}
return bytesCount;
}
@ -161,18 +177,20 @@ void _exifInfoSerialize(
) {
writer.writeString(offsets[0], object.city);
writer.writeString(offsets[1], object.country);
writer.writeString(offsets[2], object.description);
writer.writeFloat(offsets[3], object.exposureSeconds);
writer.writeFloat(offsets[4], object.f);
writer.writeLong(offsets[5], object.fileSize);
writer.writeInt(offsets[6], object.iso);
writer.writeFloat(offsets[7], object.lat);
writer.writeString(offsets[8], object.lens);
writer.writeFloat(offsets[9], object.long);
writer.writeString(offsets[10], object.make);
writer.writeFloat(offsets[11], object.mm);
writer.writeString(offsets[12], object.model);
writer.writeString(offsets[13], object.state);
writer.writeDateTime(offsets[2], object.dateTimeOriginal);
writer.writeString(offsets[3], object.description);
writer.writeFloat(offsets[4], object.exposureSeconds);
writer.writeFloat(offsets[5], object.f);
writer.writeLong(offsets[6], object.fileSize);
writer.writeInt(offsets[7], object.iso);
writer.writeFloat(offsets[8], object.lat);
writer.writeString(offsets[9], object.lens);
writer.writeFloat(offsets[10], object.long);
writer.writeString(offsets[11], object.make);
writer.writeFloat(offsets[12], object.mm);
writer.writeString(offsets[13], object.model);
writer.writeString(offsets[14], object.state);
writer.writeString(offsets[15], object.timeZone);
}
ExifInfo _exifInfoDeserialize(
@ -184,19 +202,21 @@ ExifInfo _exifInfoDeserialize(
final object = ExifInfo(
city: reader.readStringOrNull(offsets[0]),
country: reader.readStringOrNull(offsets[1]),
description: reader.readStringOrNull(offsets[2]),
exposureSeconds: reader.readFloatOrNull(offsets[3]),
f: reader.readFloatOrNull(offsets[4]),
fileSize: reader.readLongOrNull(offsets[5]),
dateTimeOriginal: reader.readDateTimeOrNull(offsets[2]),
description: reader.readStringOrNull(offsets[3]),
exposureSeconds: reader.readFloatOrNull(offsets[4]),
f: reader.readFloatOrNull(offsets[5]),
fileSize: reader.readLongOrNull(offsets[6]),
id: id,
iso: reader.readIntOrNull(offsets[6]),
lat: reader.readFloatOrNull(offsets[7]),
lens: reader.readStringOrNull(offsets[8]),
long: reader.readFloatOrNull(offsets[9]),
make: reader.readStringOrNull(offsets[10]),
mm: reader.readFloatOrNull(offsets[11]),
model: reader.readStringOrNull(offsets[12]),
state: reader.readStringOrNull(offsets[13]),
iso: reader.readIntOrNull(offsets[7]),
lat: reader.readFloatOrNull(offsets[8]),
lens: reader.readStringOrNull(offsets[9]),
long: reader.readFloatOrNull(offsets[10]),
make: reader.readStringOrNull(offsets[11]),
mm: reader.readFloatOrNull(offsets[12]),
model: reader.readStringOrNull(offsets[13]),
state: reader.readStringOrNull(offsets[14]),
timeZone: reader.readStringOrNull(offsets[15]),
);
return object;
}
@ -213,29 +233,33 @@ P _exifInfoDeserializeProp<P>(
case 1:
return (reader.readStringOrNull(offset)) as P;
case 2:
return (reader.readStringOrNull(offset)) as P;
return (reader.readDateTimeOrNull(offset)) as P;
case 3:
return (reader.readFloatOrNull(offset)) as P;
return (reader.readStringOrNull(offset)) as P;
case 4:
return (reader.readFloatOrNull(offset)) as P;
case 5:
return (reader.readLongOrNull(offset)) as P;
return (reader.readFloatOrNull(offset)) as P;
case 6:
return (reader.readIntOrNull(offset)) as P;
return (reader.readLongOrNull(offset)) as P;
case 7:
return (reader.readFloatOrNull(offset)) as P;
return (reader.readIntOrNull(offset)) as P;
case 8:
return (reader.readStringOrNull(offset)) as P;
return (reader.readFloatOrNull(offset)) as P;
case 9:
return (reader.readFloatOrNull(offset)) as P;
return (reader.readStringOrNull(offset)) as P;
case 10:
return (reader.readStringOrNull(offset)) as P;
case 11:
return (reader.readFloatOrNull(offset)) as P;
case 12:
case 11:
return (reader.readStringOrNull(offset)) as P;
case 12:
return (reader.readFloatOrNull(offset)) as P;
case 13:
return (reader.readStringOrNull(offset)) as P;
case 14:
return (reader.readStringOrNull(offset)) as P;
case 15:
return (reader.readStringOrNull(offset)) as P;
default:
throw IsarError('Unknown property with id $propertyId');
}
@ -622,6 +646,80 @@ extension ExifInfoQueryFilter
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition>
dateTimeOriginalIsNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNull(
property: r'dateTimeOriginal',
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition>
dateTimeOriginalIsNotNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNotNull(
property: r'dateTimeOriginal',
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition>
dateTimeOriginalEqualTo(DateTime? value) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'dateTimeOriginal',
value: value,
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition>
dateTimeOriginalGreaterThan(
DateTime? value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'dateTimeOriginal',
value: value,
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition>
dateTimeOriginalLessThan(
DateTime? value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'dateTimeOriginal',
value: value,
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition>
dateTimeOriginalBetween(
DateTime? lower,
DateTime? upper, {
bool includeLower = true,
bool includeUpper = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.between(
property: r'dateTimeOriginal',
lower: lower,
includeLower: includeLower,
upper: upper,
includeUpper: includeUpper,
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> descriptionIsNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNull(
@ -1956,6 +2054,152 @@ extension ExifInfoQueryFilter
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> timeZoneIsNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNull(
property: r'timeZone',
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> timeZoneIsNotNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNotNull(
property: r'timeZone',
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> timeZoneEqualTo(
String? value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'timeZone',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> timeZoneGreaterThan(
String? value, {
bool include = false,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'timeZone',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> timeZoneLessThan(
String? value, {
bool include = false,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'timeZone',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> timeZoneBetween(
String? lower,
String? upper, {
bool includeLower = true,
bool includeUpper = true,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.between(
property: r'timeZone',
lower: lower,
includeLower: includeLower,
upper: upper,
includeUpper: includeUpper,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> timeZoneStartsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.startsWith(
property: r'timeZone',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> timeZoneEndsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.endsWith(
property: r'timeZone',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> timeZoneContains(
String value,
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.contains(
property: r'timeZone',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> timeZoneMatches(
String pattern,
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.matches(
property: r'timeZone',
wildcard: pattern,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> timeZoneIsEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'timeZone',
value: '',
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> timeZoneIsNotEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
property: r'timeZone',
value: '',
));
});
}
}
extension ExifInfoQueryObject
@ -1989,6 +2233,18 @@ extension ExifInfoQuerySortBy on QueryBuilder<ExifInfo, ExifInfo, QSortBy> {
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> sortByDateTimeOriginal() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'dateTimeOriginal', Sort.asc);
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> sortByDateTimeOriginalDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'dateTimeOriginal', Sort.desc);
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> sortByDescription() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'description', Sort.asc);
@ -2132,6 +2388,18 @@ extension ExifInfoQuerySortBy on QueryBuilder<ExifInfo, ExifInfo, QSortBy> {
return query.addSortBy(r'state', Sort.desc);
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> sortByTimeZone() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'timeZone', Sort.asc);
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> sortByTimeZoneDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'timeZone', Sort.desc);
});
}
}
extension ExifInfoQuerySortThenBy
@ -2160,6 +2428,18 @@ extension ExifInfoQuerySortThenBy
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> thenByDateTimeOriginal() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'dateTimeOriginal', Sort.asc);
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> thenByDateTimeOriginalDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'dateTimeOriginal', Sort.desc);
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> thenByDescription() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'description', Sort.asc);
@ -2315,6 +2595,18 @@ extension ExifInfoQuerySortThenBy
return query.addSortBy(r'state', Sort.desc);
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> thenByTimeZone() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'timeZone', Sort.asc);
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> thenByTimeZoneDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'timeZone', Sort.desc);
});
}
}
extension ExifInfoQueryWhereDistinct
@ -2333,6 +2625,12 @@ extension ExifInfoQueryWhereDistinct
});
}
QueryBuilder<ExifInfo, ExifInfo, QDistinct> distinctByDateTimeOriginal() {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'dateTimeOriginal');
});
}
QueryBuilder<ExifInfo, ExifInfo, QDistinct> distinctByDescription(
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
@ -2409,6 +2707,13 @@ extension ExifInfoQueryWhereDistinct
return query.addDistinctBy(r'state', caseSensitive: caseSensitive);
});
}
QueryBuilder<ExifInfo, ExifInfo, QDistinct> distinctByTimeZone(
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'timeZone', caseSensitive: caseSensitive);
});
}
}
extension ExifInfoQueryProperty
@ -2431,6 +2736,13 @@ extension ExifInfoQueryProperty
});
}
QueryBuilder<ExifInfo, DateTime?, QQueryOperations>
dateTimeOriginalProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'dateTimeOriginal');
});
}
QueryBuilder<ExifInfo, String?, QQueryOperations> descriptionProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'description');
@ -2502,4 +2814,10 @@ extension ExifInfoQueryProperty
return query.addPropertyName(r'state');
});
}
QueryBuilder<ExifInfo, String?, QQueryOperations> timeZoneProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'timeZone');
});
}
}

View file

@ -194,6 +194,7 @@ class ImmichAppBarDialog extends HookConsumerWidget {
Navigator.of(context).pop();
launchUrl(
Uri.parse('https://immich.app'),
mode: LaunchMode.externalApplication,
);
},
child: Text(
@ -213,6 +214,7 @@ class ImmichAppBarDialog extends HookConsumerWidget {
Navigator.of(context).pop();
launchUrl(
Uri.parse('https://github.com/immich-app/immich'),
mode: LaunchMode.externalApplication,
);
},
child: Text(

View file

@ -182,19 +182,36 @@ class AppBarServerInfo extends HookConsumerWidget {
child: Container(
width: 200,
padding: const EdgeInsets.only(right: 10.0),
child: Text(
getServerUrl() ?? '--',
style: TextStyle(
fontSize: 11,
color: Theme.of(context)
.textTheme
.labelSmall
?.color
?.withOpacity(0.5),
fontWeight: FontWeight.bold,
overflow: TextOverflow.ellipsis,
child: Tooltip(
verticalOffset: 0,
decoration: BoxDecoration(
color:
Theme.of(context).primaryColor.withOpacity(0.9),
borderRadius: BorderRadius.circular(10),
),
textStyle: TextStyle(
color: Theme.of(context).brightness == Brightness.dark
? Colors.black
: Colors.white,
fontWeight: FontWeight.bold,
),
message: getServerUrl() ?? '--',
preferBelow: false,
triggerMode: TooltipTriggerMode.tap,
child: Text(
getServerUrl() ?? '--',
style: TextStyle(
fontSize: 11,
color: Theme.of(context)
.textTheme
.labelSmall
?.color
?.withOpacity(0.5),
fontWeight: FontWeight.bold,
overflow: TextOverflow.ellipsis,
),
textAlign: TextAlign.end,
),
textAlign: TextAlign.end,
),
),
),

View file

@ -31,7 +31,7 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
const widgetSize = 30.0;
buildProfilePhoto() {
buildProfileIndicator() {
return InkWell(
onTap: () => showDialog(
context: context,
@ -39,37 +39,33 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
builder: (ctx) => const ImmichAppBarDialog(),
),
borderRadius: BorderRadius.circular(12),
child: authState.profileImagePath.isEmpty || user == null
? const Icon(
Icons.face_outlined,
size: widgetSize,
)
: UserCircleAvatar(
radius: 15,
size: 27,
user: user,
),
);
}
buildProfileIndicator() {
return Badge(
label: Container(
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(widgetSize / 2),
),
child: const Icon(
Icons.info,
color: Color.fromARGB(255, 243, 188, 106),
size: widgetSize / 2,
child: Badge(
label: Container(
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(widgetSize / 2),
),
child: const Icon(
Icons.info,
color: Color.fromARGB(255, 243, 188, 106),
size: widgetSize / 2,
),
),
backgroundColor: Colors.transparent,
alignment: Alignment.bottomRight,
isLabelVisible: serverInfoState.isVersionMismatch,
offset: const Offset(2, 2),
child: authState.profileImagePath.isEmpty || user == null
? const Icon(
Icons.face_outlined,
size: widgetSize,
)
: UserCircleAvatar(
radius: 15,
size: 27,
user: user,
),
),
backgroundColor: Colors.transparent,
alignment: Alignment.bottomRight,
isLabelVisible: serverInfoState.isVersionMismatch,
offset: const Offset(2, 2),
child: buildProfilePhoto(),
);
}

View file

@ -125,7 +125,7 @@ void (empty response body)
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **getActivities**
> List<ActivityResponseDto> getActivities(albumId, assetId, type)
> List<ActivityResponseDto> getActivities(albumId, assetId, type, userId)
@ -151,9 +151,10 @@ final api_instance = ActivityApi();
final albumId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
final assetId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
final type = ; // ReactionType |
final userId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
try {
final result = api_instance.getActivities(albumId, assetId, type);
final result = api_instance.getActivities(albumId, assetId, type, userId);
print(result);
} catch (e) {
print('Exception when calling ActivityApi->getActivities: $e\n');
@ -167,6 +168,7 @@ Name | Type | Description | Notes
**albumId** | **String**| |
**assetId** | **String**| | [optional]
**type** | [**ReactionType**](.md)| | [optional]
**userId** | **String**| | [optional]
### Return type

View file

@ -111,7 +111,9 @@ class ActivityApi {
/// * [String] assetId:
///
/// * [ReactionType] type:
Future<Response> getActivitiesWithHttpInfo(String albumId, { String? assetId, ReactionType? type, }) async {
///
/// * [String] userId:
Future<Response> getActivitiesWithHttpInfo(String albumId, { String? assetId, ReactionType? type, String? userId, }) async {
// ignore: prefer_const_declarations
final path = r'/activity';
@ -129,6 +131,9 @@ class ActivityApi {
if (type != null) {
queryParams.addAll(_queryParams('', 'type', type));
}
if (userId != null) {
queryParams.addAll(_queryParams('', 'userId', userId));
}
const contentTypes = <String>[];
@ -151,8 +156,10 @@ class ActivityApi {
/// * [String] assetId:
///
/// * [ReactionType] type:
Future<List<ActivityResponseDto>?> getActivities(String albumId, { String? assetId, ReactionType? type, }) async {
final response = await getActivitiesWithHttpInfo(albumId, assetId: assetId, type: type, );
///
/// * [String] userId:
Future<List<ActivityResponseDto>?> getActivities(String albumId, { String? assetId, ReactionType? type, String? userId, }) async {
final response = await getActivitiesWithHttpInfo(albumId, assetId: assetId, type: type, userId: userId, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}

View file

@ -27,7 +27,7 @@ void main() {
// TODO
});
//Future<List<ActivityResponseDto>> getActivities(String albumId, { String assetId, ReactionType type }) async
//Future<List<ActivityResponseDto>> getActivities(String albumId, { String assetId, ReactionType type, String userId }) async
test('test getActivities', () async {
// TODO
});

View file

@ -1401,7 +1401,7 @@ packages:
source: hosted
version: "2.1.3"
timezone:
dependency: transitive
dependency: "direct main"
description:
name: timezone
sha256: "1cfd8ddc2d1cfd836bc93e67b9be88c3adaeca6f40a00ca999104c30693cdca0"

View file

@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: "none"
version: 1.83.0+107
version: 1.84.0+108
isar_version: &isar_version 3.1.0+1
environment:
@ -52,6 +52,7 @@ dependencies:
crypto: ^3.0.3 # TODO remove once native crypto is used on iOS
wakelock_plus: ^1.1.1
flutter_local_notifications: ^15.1.0+1
timezone: ^0.9.2
openapi:
path: openapi

View file

@ -30,6 +30,15 @@
"schema": {
"$ref": "#/components/schemas/ReactionType"
}
},
{
"name": "userId",
"required": false,
"in": "query",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": {
@ -5635,7 +5644,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "1.83.0",
"version": "1.84.0",
"contact": {}
},
"tags": [],

View file

@ -1,12 +1,12 @@
{
"name": "immich",
"version": "1.83.0",
"version": "1.84.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "immich",
"version": "1.83.0",
"version": "1.84.0",
"license": "UNLICENSED",
"dependencies": {
"@babel/runtime": "^7.22.11",

View file

@ -1,6 +1,6 @@
{
"name": "immich",
"version": "1.83.0",
"version": "1.84.0",
"description": "",
"author": "",
"private": true,

View file

@ -38,6 +38,9 @@ export class ActivitySearchDto extends ActivityDto {
@Optional()
@ApiProperty({ enumName: 'ReactionType', enum: ReactionType })
type?: ReactionType;
@ValidateUUID({ optional: true })
userId?: string;
}
const isComment = (dto: ActivityCreateDto) => dto.type === 'comment';

View file

@ -28,6 +28,7 @@ export class ActivityService {
async getAll(authUser: AuthUserDto, dto: ActivitySearchDto): Promise<ActivityResponseDto[]> {
await this.access.requirePermission(authUser, Permission.ALBUM_READ, dto.albumId);
const activities = await this.repository.search({
userId: dto.userId,
albumId: dto.albumId,
assetId: dto.assetId,
isLiked: dto.type && dto.type === ReactionType.LIKE,

View file

@ -98,7 +98,14 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As
tags: entity.tags?.map(mapTag),
people: entity.faces
?.map(mapFace)
.filter((person): person is PersonResponseDto => person !== null && !person.isHidden),
.filter((person): person is PersonResponseDto => person !== null && !person.isHidden)
.reduce((people, person) => {
const existingPerson = people.find((p) => p.id === person.id);
if (!existingPerson) {
people.push(person);
}
return people;
}, [] as PersonResponseDto[]),
checksum: entity.checksum.toString('base64'),
stackParentId: entity.stackParentId,
stack: withStack ? entity.stack?.map((a) => mapAsset(a, { stripMetadata })) ?? undefined : undefined,

View file

@ -109,7 +109,9 @@ export class AssetRepository implements IAssetRepository {
faces: {
person: true,
},
stack: true,
stack: {
exifInfo: true,
},
},
// We are specifically asking for this asset. Return it even if it is soft deleted
withDeleted: true,

View file

@ -134,6 +134,29 @@ describe(`${ActivityController.name} (e2e)`, () => {
expect(body[0]).toEqual(reaction);
});
it('should filter by userId', async () => {
const [reaction] = await Promise.all([
api.activityApi.create(server, admin.accessToken, { albumId: album.id, type: ReactionType.LIKE }),
]);
const response1 = await request(server)
.get('/activity')
.query({ albumId: album.id, userId: uuidStub.notFound })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(response1.status).toEqual(200);
expect(response1.body.length).toBe(0);
const response2 = await request(server)
.get('/activity')
.query({ albumId: album.id, userId: admin.userId })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(response2.status).toEqual(200);
expect(response2.body.length).toBe(1);
expect(response2.body[0]).toEqual(reaction);
});
it('should filter by assetId', async () => {
const [reaction] = await Promise.all([
api.activityApi.create(server, admin.accessToken, {

View file

@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.83.0
* The version of the OpenAPI document: 1.84.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View file

@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.83.0
* The version of the OpenAPI document: 1.84.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View file

@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.83.0
* The version of the OpenAPI document: 1.84.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View file

@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.83.0
* The version of the OpenAPI document: 1.84.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View file

@ -32,7 +32,7 @@
export let showDownloadButton: boolean;
export let showDetailButton: boolean;
export let showSlideshow = false;
export let hasStackChildern = false;
export let hasStackChildren = false;
$: isOwner = asset.ownerId === $page.data.user?.id;
@ -176,7 +176,7 @@
/>
<MenuOption on:click={() => onMenuClick('asProfileImage')} text="As profile picture" />
{#if hasStackChildern}
{#if hasStackChildren}
<MenuOption on:click={() => onMenuClick('unstack')} text="Un-Stack" />
{/if}

View file

@ -29,25 +29,24 @@
import { browser } from '$app/environment';
import { handleError } from '$lib/utils/handle-error';
import type { AssetStore } from '$lib/stores/assets.store';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import ProgressBar, { ProgressBarStatus } from '../shared-components/progress-bar/progress-bar.svelte';
import { shouldIgnoreShortcut } from '$lib/utils/shortcut';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { SlideshowHistory } from '$lib/utils/slideshow-history';
import { featureFlags } from '$lib/stores/server-config.store';
import {
mdiChevronLeft,
mdiHeartOutline,
mdiHeart,
mdiCommentOutline,
mdiChevronLeft,
mdiChevronRight,
mdiClose,
mdiImageBrokenVariant,
mdiPause,
mdiPlay,
} from '@mdi/js';
import Icon from '$lib/components/elements/icon.svelte';
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
import { stackAssetsStore } from '$lib/stores/stacked-asset.store';
import ActivityViewer from './activity-viewer.svelte';
import { SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import SlideshowBar from './slideshow-bar.svelte';
export let assetStore: AssetStore | null = null;
export let asset: AssetResponseDto;
@ -62,6 +61,14 @@
let reactions: ActivityResponseDto[] = [];
const { setAssetId } = assetViewingStore;
const {
restartProgress: restartSlideshowProgress,
stopProgress: stopSlideshowProgress,
slideshowShuffle,
slideshowState,
} = slideshowStore;
const dispatch = createEventDispatcher<{
archived: AssetResponseDto;
unarchived: AssetResponseDto;
@ -82,6 +89,8 @@
let shouldShowDownloadButton = sharedLink ? sharedLink.allowDownload : !asset.isOffline;
let shouldShowDetailButton = asset.hasMetadata;
let canCopyImagesToClipboard: boolean;
let slideshowStateUnsubscribe: () => void;
let shuffleSlideshowUnsubscribe: () => void;
let previewStackedAsset: AssetResponseDto | undefined;
let isShowActivity = false;
let isLiked: ActivityResponseDto | null = null;
@ -123,15 +132,18 @@
};
const getFavorite = async () => {
if (album) {
if (album && user) {
try {
const { data } = await api.activityApi.getActivities({
userId: user.id,
assetId: asset.id,
albumId: album.id,
type: ReactionType.Like,
});
if (data.length > 0) {
isLiked = data[0];
} else {
isLiked = null;
}
} catch (error) {
handleError(error, "Can't get Favorite");
@ -161,6 +173,23 @@
onMount(async () => {
document.addEventListener('keydown', onKeyboardPress);
slideshowStateUnsubscribe = slideshowState.subscribe((value) => {
if (value === SlideshowState.PlaySlideshow) {
slideshowHistory.reset();
slideshowHistory.queue(asset.id);
handlePlaySlideshow();
} else if (value === SlideshowState.StopSlideshow) {
handleStopSlideshow();
}
});
shuffleSlideshowUnsubscribe = slideshowShuffle.subscribe((value) => {
if (value) {
slideshowHistory.reset();
slideshowHistory.queue(asset.id);
}
});
if (!sharedLink) {
await getAllAlbums();
}
@ -184,6 +213,14 @@
if (browser) {
document.removeEventListener('keydown', onKeyboardPress);
}
if (slideshowStateUnsubscribe) {
slideshowStateUnsubscribe();
}
if (shuffleSlideshowUnsubscribe) {
shuffleSlideshowUnsubscribe();
}
});
$: asset.id && !sharedLink && getAllAlbums(); // Update the album information when the asset ID changes
@ -262,11 +299,31 @@
const closeViewer = () => dispatch('close');
const navigateAssetRandom = async () => {
if (!assetStore) {
return;
}
const asset = await assetStore.getRandomAsset();
if (!asset) {
return;
}
slideshowHistory.queue(asset.id);
setAssetId(asset.id);
$restartSlideshowProgress = true;
};
const navigateAssetForward = async (e?: Event) => {
if (isSlideshowMode && assetStore && progressBar) {
if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowShuffle) {
return slideshowHistory.next() || navigateAssetRandom();
}
if ($slideshowState === SlideshowState.PlaySlideshow && assetStore) {
const hasNext = await assetStore.getNextAssetId(asset.id);
if (hasNext) {
progressBar.restart(true);
$restartSlideshowProgress = true;
} else {
await handleStopSlideshow();
}
@ -277,8 +334,13 @@
};
const navigateAssetBackward = (e?: Event) => {
if (isSlideshowMode && progressBar) {
progressBar.restart(true);
if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowShuffle) {
slideshowHistory.previous();
return;
}
if ($slideshowState === SlideshowState.PlaySlideshow) {
$restartSlideshowProgress = true;
}
e?.stopPropagation();
@ -426,19 +488,21 @@
* Slide show mode
*/
let isSlideshowMode = false;
let assetViewerHtmlElement: HTMLElement;
let progressBar: ProgressBar;
let progressBarStatus: ProgressBarStatus;
const slideshowHistory = new SlideshowHistory((assetId: string) => {
setAssetId(assetId);
$restartSlideshowProgress = true;
});
const handleVideoStarted = () => {
if (isSlideshowMode) {
progressBar.restart(false);
if ($slideshowState === SlideshowState.PlaySlideshow) {
$stopSlideshowProgress = true;
}
};
const handleVideoEnded = async () => {
if (isSlideshowMode) {
if ($slideshowState === SlideshowState.PlaySlideshow) {
await navigateAssetForward();
}
};
@ -448,19 +512,20 @@
await assetViewerHtmlElement.requestFullscreen();
} catch (error) {
console.error('Error entering fullscreen', error);
} finally {
isSlideshowMode = true;
$slideshowState = SlideshowState.StopSlideshow;
}
};
const handleStopSlideshow = async () => {
try {
await document.exitFullscreen();
if (document.fullscreenElement) {
await document.exitFullscreen();
}
} catch (error) {
console.error('Error exiting fullscreen', error);
} finally {
isSlideshowMode = false;
progressBar.restart(false);
$stopSlideshowProgress = true;
$slideshowState = SlideshowState.None;
}
};
@ -484,7 +549,7 @@
}
asset.stackCount = 0;
asset.stack = [];
assetStore?.updateAsset(asset);
assetStore?.updateAsset(asset, true);
dispatch('unstack');
notificationController.show({ type: NotificationType.Info, message: 'Un-stacked', timeout: 1500 });
@ -497,31 +562,10 @@
<section
id="immich-asset-viewer"
class="fixed left-0 top-0 z-[1001] grid h-screen w-screen grid-cols-4 grid-rows-[64px_1fr] overflow-y-hidden bg-black"
bind:this={assetViewerHtmlElement}
>
<div class="z-[1000] col-span-4 col-start-1 row-span-1 row-start-1 transition-transform">
{#if isSlideshowMode}
<!-- SlideShowController -->
<div class="flex">
<div class="m-4 flex gap-2">
<CircleIconButton icon={mdiClose} on:click={handleStopSlideshow} title="Exit Slideshow" />
<CircleIconButton
icon={progressBarStatus === ProgressBarStatus.Paused ? mdiPlay : mdiPause}
on:click={() => (progressBarStatus === ProgressBarStatus.Paused ? progressBar.play() : progressBar.pause())}
title={progressBarStatus === ProgressBarStatus.Paused ? 'Play' : 'Pause'}
/>
<CircleIconButton icon={mdiChevronLeft} on:click={navigateAssetBackward} title="Previous" />
<CircleIconButton icon={mdiChevronRight} on:click={navigateAssetForward} title="Next" />
</div>
<ProgressBar
autoplay
bind:this={progressBar}
bind:status={progressBarStatus}
on:done={navigateAssetForward}
duration={5000}
/>
</div>
{:else}
<!-- Top navigation bar -->
{#if $slideshowState === SlideshowState.None}
<div class="z-[1002] col-span-4 col-start-1 row-span-1 row-start-1 transition-transform">
<AssetViewerNavBar
{asset}
isMotionPhotoPlaying={shouldPlayMotionPhoto}
@ -531,7 +575,7 @@
showDownloadButton={shouldShowDownloadButton}
showDetailButton={shouldShowDetailButton}
showSlideshow={!!assetStore}
hasStackChildern={$stackAssetsStore.length > 0}
hasStackChildren={$stackAssetsStore.length > 0}
on:goBack={closeViewer}
on:showDetail={showDetailInfoHandler}
on:download={() => downloadFile(asset)}
@ -544,19 +588,30 @@
on:toggleArchive={toggleArchive}
on:asProfileImage={() => (isShowProfileImageCrop = true)}
on:runJob={({ detail: job }) => handleRunJob(job)}
on:playSlideShow={handlePlaySlideshow}
on:playSlideShow={() => ($slideshowState = SlideshowState.PlaySlideshow)}
on:unstack={handleUnstack}
/>
{/if}
</div>
</div>
{/if}
{#if !isSlideshowMode && showNavigation}
<div class="column-span-1 z-[999] col-start-1 row-span-1 row-start-2 mb-[60px] justify-self-start">
{#if $slideshowState === SlideshowState.None && showNavigation}
<div class="z-[1001] column-span-1 col-start-1 row-span-1 row-start-2 mb-[60px] justify-self-start">
<NavigationArea on:click={navigateAssetBackward}><Icon path={mdiChevronLeft} size="36" /></NavigationArea>
</div>
{/if}
<!-- Asset Viewer -->
<div class="relative col-span-4 col-start-1 row-span-full row-start-1">
<div class="z-[1000] relative col-start-1 col-span-4 row-start-1 row-span-full" bind:this={assetViewerHtmlElement}>
{#if $slideshowState != SlideshowState.None}
<div class="z-[1000] absolute w-full flex">
<SlideshowBar
on:prev={navigateAssetBackward}
on:next={navigateAssetForward}
on:close={() => ($slideshowState = SlideshowState.StopSlideshow)}
/>
</div>
{/if}
{#if previewStackedAsset}
{#key previewStackedAsset.id}
{#if previewStackedAsset.type === AssetTypeEnum.Image}
@ -602,7 +657,7 @@
on:onVideoStarted={handleVideoStarted}
/>
{/if}
{#if isShared}
{#if $slideshowState === SlideshowState.None && isShared}
<div class="z-[9999] absolute bottom-0 right-0 mb-6 mr-6 justify-self-end">
<div
class="w-full h-14 flex p-4 text-white items-center justify-center rounded-full gap-4 bg-immich-dark-bg bg-opacity-60"
@ -664,19 +719,17 @@
{/if}
</div>
<!-- Stack & Stack Controller -->
{#if !isSlideshowMode && showNavigation}
<div class="z-[999] col-span-1 col-start-4 row-span-1 row-start-2 mb-[60px] justify-self-end">
{#if $slideshowState === SlideshowState.None && showNavigation}
<div class="z-[1001] col-span-1 col-start-4 row-span-1 row-start-2 mb-[60px] justify-self-end">
<NavigationArea on:click={navigateAssetForward}><Icon path={mdiChevronRight} size="36" /></NavigationArea>
</div>
{/if}
{#if !isSlideshowMode && $isShowDetail}
{#if $slideshowState === SlideshowState.None && $isShowDetail}
<div
transition:fly={{ duration: 150 }}
id="detail-panel"
class="z-[1002] row-start-1 row-span-5 w-[360px] overflow-y-auto bg-immich-bg transition-all dark:border-l dark:border-l-immich-dark-gray dark:bg-immich-dark-bg"
class="z-[1002] row-start-1 row-span-4 w-[360px] overflow-y-auto bg-immich-bg transition-all dark:border-l dark:border-l-immich-dark-gray dark:bg-immich-dark-bg"
translate="yes"
>
<DetailPanel

View file

@ -0,0 +1,78 @@
<script lang="ts">
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import ProgressBar, { ProgressBarStatus } from '../shared-components/progress-bar/progress-bar.svelte';
import { slideshowStore } from '$lib/stores/slideshow.store';
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
import {
mdiChevronLeft,
mdiChevronRight,
mdiClose,
mdiPause,
mdiPlay,
mdiShuffle,
mdiShuffleDisabled,
} from '@mdi/js';
const { slideshowShuffle } = slideshowStore;
const { restartProgress, stopProgress } = slideshowStore;
let progressBarStatus: ProgressBarStatus;
let progressBar: ProgressBar;
let unsubscribeRestart: () => void;
let unsubscribeStop: () => void;
const dispatch = createEventDispatcher<{
next: void;
prev: void;
close: void;
}>();
onMount(() => {
unsubscribeRestart = restartProgress.subscribe((value) => {
if (value) {
progressBar.restart(value);
}
});
unsubscribeStop = stopProgress.subscribe((value) => {
if (value) {
progressBar.restart(false);
}
});
});
onDestroy(() => {
if (unsubscribeRestart) {
unsubscribeRestart();
}
if (unsubscribeStop) {
unsubscribeStop();
}
});
</script>
<div class="m-4 flex gap-2">
<CircleIconButton icon={mdiClose} on:click={() => dispatch('close')} title="Exit Slideshow" />
{#if $slideshowShuffle}
<CircleIconButton icon={mdiShuffle} on:click={() => ($slideshowShuffle = false)} title="Shuffle" />
{:else}
<CircleIconButton icon={mdiShuffleDisabled} on:click={() => ($slideshowShuffle = true)} title="No shuffle" />
{/if}
<CircleIconButton
icon={progressBarStatus === ProgressBarStatus.Paused ? mdiPlay : mdiPause}
on:click={() => (progressBarStatus === ProgressBarStatus.Paused ? progressBar.play() : progressBar.pause())}
title={progressBarStatus === ProgressBarStatus.Paused ? 'Play' : 'Pause'}
/>
<CircleIconButton icon={mdiChevronLeft} on:click={() => dispatch('prev')} title="Previous" />
<CircleIconButton icon={mdiChevronRight} on:click={() => dispatch('next')} title="Next" />
</div>
<ProgressBar
autoplay
bind:this={progressBar}
bind:status={progressBarStatus}
on:done={() => dispatch('next')}
duration={5000}
/>

View file

@ -12,7 +12,7 @@
export let scrollbar = true;
export let admin = false;
$: scrollbarClass = scrollbar ? 'immich-scrollbar p-4 pb-8' : 'scrollbar-hidden pl-4';
$: scrollbarClass = scrollbar ? 'immich-scrollbar p-4 pb-8' : 'scrollbar-hidden';
$: hasTitleClass = title ? 'top-16 h-[calc(100%-theme(spacing.16))]' : 'top-0 h-full';
</script>

View file

@ -21,11 +21,27 @@
$: hoverY = height - windowHeight + clientY;
$: scrollY = toScrollY(timelineY);
$: segments = $assetStore.buckets.map((bucket) => ({
count: bucket.assets.length,
height: toScrollY(bucket.bucketHeight),
timeGroup: bucket.bucketDate,
}));
class Segment {
public count;
public height;
public timeGroup;
constructor({ count = 0, height = 0, timeGroup = '' }) {
this.count = count;
this.height = height;
this.timeGroup = timeGroup;
}
}
$: segments = $assetStore.buckets.map(
(bucket) =>
new Segment({
count: bucket.assets.length,
height: toScrollY(bucket.bucketHeight),
timeGroup: bucket.bucketDate,
}),
);
const dispatch = createEventDispatcher<{ scrollTimeline: number }>();
const scrollTimeline = () => dispatch('scrollTimeline', toTimelineY(hoverY));
@ -51,6 +67,23 @@
isAnimating = false;
});
};
const prevYearSegmentHeight = (segments: Segment[], index: number) => {
var prevYear = null;
var height = 0;
for (var i = index; i >= 0; i--) {
const curr = segments[i];
const currYear = fromLocalDateTime(curr.timeGroup).year;
if (prevYear && prevYear != currYear) {
break;
}
height += curr.height;
prevYear = currYear;
}
return height;
};
</script>
<svelte:window bind:innerHeight={windowHeight} />
@ -96,6 +129,7 @@
{@const date = fromLocalDateTime(segment.timeGroup)}
{@const year = date.year}
{@const label = `${date.toLocaleString({ month: 'short' })} ${year}`}
{@const lastGroupYear = fromLocalDateTime(segments[index - 1]?.timeGroup).year}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
@ -105,10 +139,10 @@
aria-label={segment.timeGroup + ' ' + segment.count}
on:mousemove={() => (hoverLabel = label)}
>
{#if new Date(segments[index - 1]?.timeGroup).getFullYear() !== year}
{#if lastGroupYear !== year && (index == 0 || prevYearSegmentHeight(segments, index - 1) > 16)}
<div
aria-label={segment.timeGroup + ' ' + segment.count}
class="absolute right-0 z-10 pr-5 text-xs font-medium dark:text-immich-dark-fg"
class="absolute right-0 z-10 pr-5 text-xs font-medium dark:text-immich-dark-fg font-mono"
>
{year}
</div>

View file

@ -304,7 +304,20 @@ export class AssetStore {
return this.assetToBucket[assetId]?.bucketIndex ?? null;
}
updateAsset(_asset: AssetResponseDto) {
async getRandomAsset(): Promise<AssetResponseDto | null> {
const bucket = this.buckets[Math.floor(Math.random() * this.buckets.length)] || null;
if (!bucket) {
return null;
}
if (bucket.assets.length === 0) {
await this.loadBucket(bucket.bucketDate, BucketPosition.Unknown);
}
return bucket.assets[Math.floor(Math.random() * bucket.assets.length)] || null;
}
updateAsset(_asset: AssetResponseDto, recalculate = false) {
const asset = this.assets.find((asset) => asset.id === _asset.id);
if (!asset) {
return;
@ -312,7 +325,7 @@ export class AssetStore {
Object.assign(asset, _asset);
this.emit(false);
this.emit(recalculate);
}
removeAssets(ids: string[]) {

View file

@ -0,0 +1,45 @@
import { persisted } from 'svelte-local-storage-store';
import { writable } from 'svelte/store';
export enum SlideshowState {
PlaySlideshow = 'play-slideshow',
StopSlideshow = 'stop-slideshow',
None = 'none',
}
function createSlideshowStore() {
const restartState = writable<boolean>(false);
const stopState = writable<boolean>(false);
const slideshowShuffle = persisted<boolean>('slideshow-shuffle', true);
const slideshowState = writable<SlideshowState>(SlideshowState.None);
return {
restartProgress: {
subscribe: restartState.subscribe,
set: (value: boolean) => {
// Trigger an action whenever the restartProgress is set to true. Automatically
// reset the restart state after that
if (value) {
restartState.set(true);
restartState.set(false);
}
},
},
stopProgress: {
subscribe: stopState.subscribe,
set: (value: boolean) => {
// Trigger an action whenever the stopProgress is set to true. Automatically
// reset the stop state after that
if (value) {
stopState.set(true);
stopState.set(false);
}
},
},
slideshowShuffle,
slideshowState,
};
}
export const slideshowStore = createSlideshowStore();

View file

@ -0,0 +1,40 @@
export class SlideshowHistory {
private history: string[] = [];
private index = 0;
constructor(private onChange: (assetId: string) => void) {}
reset() {
this.history = [];
this.index = 0;
}
queue(assetId: string) {
this.history.push(assetId);
// If we were at the end of the slideshow history, move the index to the new end
if (this.index === this.history.length - 2) {
this.index++;
}
}
next(): boolean {
if (this.index === this.history.length - 1) {
return false;
}
this.index++;
this.onChange(this.history[this.index]);
return true;
}
previous(): boolean {
if (this.index === 0) {
return false;
}
this.index--;
this.onChange(this.history[this.index]);
return true;
}
}

View file

@ -29,6 +29,7 @@
import { AppRoute, dateFormats } from '$lib/constants';
import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { AssetStore } from '$lib/stores/assets.store';
import { locale } from '$lib/stores/preferences.store';
import { downloadArchive } from '$lib/utils/asset-utils';
@ -52,7 +53,8 @@
export let data: PageData;
let { isViewing: showAssetViewer } = assetViewingStore;
let { isViewing: showAssetViewer, setAssetId } = assetViewingStore;
let { slideshowState, slideshowShuffle } = slideshowStore;
let album = data.album;
$: album = data.album;
@ -108,6 +110,14 @@
}
});
const handleStartSlideshow = async () => {
const asset = $slideshowShuffle ? await assetStore.getRandomAsset() : assetStore.assets[0];
if (asset) {
setAssetId(asset.id);
$slideshowState = SlideshowState.PlaySlideshow;
}
};
const handleEscape = () => {
if (viewMode === ViewMode.SELECT_USERS) {
viewMode = ViewMode.VIEW;
@ -365,6 +375,9 @@
<CircleIconButton title="Album options" on:click={handleOpenAlbumOptions} icon={mdiDotsVertical}>
{#if viewMode === ViewMode.ALBUM_OPTIONS}
<ContextMenu {...contextMenuPosition}>
{#if album.assetCount !== 0}
<MenuOption on:click={handleStartSlideshow} text="Slideshow" />
{/if}
<MenuOption on:click={() => (viewMode = ViewMode.SELECT_THUMBNAIL)} text="Set album cover" />
</ContextMenu>
{/if}

View file

@ -87,7 +87,7 @@
</div>
<AssetGrid forceDelete {assetStore} {assetInteractionStore}>
<p class="font-medium text-gray-500/60 dark:text-gray-300/60 py-4">
<p class="font-medium text-gray-500/60 dark:text-gray-300/60 p-4">
Trashed items will be permanently deleted after {$serverConfig.trashDays} days.
</p>
<EmptyPlaceholder