Compare commits
9 Commits
v1.116.0
...
fix/scroll
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e628e6a807 | ||
|
|
971ba63447 | ||
|
|
d5ee823fbc | ||
|
|
26f33652e1 | ||
|
|
c86fa81e47 | ||
|
|
42ad3e6bb0 | ||
|
|
a6e703ed6b | ||
|
|
b6f871786c | ||
|
|
62a490eca2 |
@@ -33,6 +33,7 @@
|
|||||||
<a href="readme_i18n/README_pt_BR.md">Português Brasileiro</a>
|
<a href="readme_i18n/README_pt_BR.md">Português Brasileiro</a>
|
||||||
<a href="readme_i18n/README_sv_SE.md">Svenska</a>
|
<a href="readme_i18n/README_sv_SE.md">Svenska</a>
|
||||||
<a href="readme_i18n/README_ar_JO.md">العربية</a>
|
<a href="readme_i18n/README_ar_JO.md">العربية</a>
|
||||||
|
<a href="readme_i18n/README_vi_VN.md">Tiếng Việt</a>
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -333,7 +333,11 @@ You may need to add mount points or docker volumes for the following internal co
|
|||||||
- `immich-machine-learning:/.cache`
|
- `immich-machine-learning:/.cache`
|
||||||
- `redis:/data`
|
- `redis:/data`
|
||||||
|
|
||||||
The non-root user/group needs read/write access to the volume mounts, including `UPLOAD_LOCATION`.
|
The non-root user/group needs read/write access to the volume mounts, including `UPLOAD_LOCATION` and `/cache` for machine-learning.
|
||||||
|
|
||||||
|
:::note Docker Compose Volumes
|
||||||
|
The Docker Compose top level volume element does not support non-root access, all of the above volumes must be local volume mounts.
|
||||||
|
:::
|
||||||
|
|
||||||
For a further hardened system, you can add the following block to every container except for `immich_postgres`.
|
For a further hardened system, you can add the following block to every container except for `immich_postgres`.
|
||||||
|
|
||||||
|
|||||||
@@ -401,7 +401,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 175;
|
CURRENT_PROJECT_VERSION = 176;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@@ -543,7 +543,7 @@
|
|||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 175;
|
CURRENT_PROJECT_VERSION = 176;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@@ -571,7 +571,7 @@
|
|||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 175;
|
CURRENT_PROJECT_VERSION = 176;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
|||||||
@@ -58,11 +58,11 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.115.0</string>
|
<string>1.116.0</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>175</string>
|
<string>176</string>
|
||||||
<key>FLTEnableImpeller</key>
|
<key>FLTEnableImpeller</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
|||||||
@@ -51,107 +51,109 @@ class CropImagePage extends HookWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
backgroundColor: context.scaffoldBackgroundColor,
|
backgroundColor: context.scaffoldBackgroundColor,
|
||||||
body: LayoutBuilder(
|
body: SafeArea(
|
||||||
builder: (BuildContext context, BoxConstraints constraints) {
|
child: LayoutBuilder(
|
||||||
return Column(
|
builder: (BuildContext context, BoxConstraints constraints) {
|
||||||
children: [
|
return Column(
|
||||||
Container(
|
children: [
|
||||||
padding: const EdgeInsets.only(top: 20),
|
Container(
|
||||||
width: constraints.maxWidth * 0.9,
|
padding: const EdgeInsets.only(top: 20),
|
||||||
height: constraints.maxHeight * 0.6,
|
width: constraints.maxWidth * 0.9,
|
||||||
child: CropImage(
|
height: constraints.maxHeight * 0.6,
|
||||||
controller: cropController,
|
child: CropImage(
|
||||||
image: image,
|
controller: cropController,
|
||||||
gridColor: Colors.white,
|
image: image,
|
||||||
),
|
gridColor: Colors.white,
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: Container(
|
|
||||||
width: double.infinity,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: context.scaffoldBackgroundColor,
|
|
||||||
borderRadius: const BorderRadius.only(
|
|
||||||
topLeft: Radius.circular(20),
|
|
||||||
topRight: Radius.circular(20),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: Center(
|
),
|
||||||
child: Column(
|
Expanded(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
child: Container(
|
||||||
children: [
|
width: double.infinity,
|
||||||
Padding(
|
decoration: BoxDecoration(
|
||||||
padding: const EdgeInsets.only(
|
color: context.scaffoldBackgroundColor,
|
||||||
left: 20,
|
borderRadius: const BorderRadius.only(
|
||||||
right: 20,
|
topLeft: Radius.circular(20),
|
||||||
bottom: 10,
|
topRight: Radius.circular(20),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
left: 20,
|
||||||
|
right: 20,
|
||||||
|
bottom: 10,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
Icons.rotate_left,
|
||||||
|
color: Theme.of(context).iconTheme.color,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
cropController.rotateLeft();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
Icons.rotate_right,
|
||||||
|
color: Theme.of(context).iconTheme.color,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
cropController.rotateRight();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
children: [
|
children: <Widget>[
|
||||||
IconButton(
|
_AspectRatioButton(
|
||||||
icon: Icon(
|
cropController: cropController,
|
||||||
Icons.rotate_left,
|
aspectRatio: aspectRatio,
|
||||||
color: Theme.of(context).iconTheme.color,
|
ratio: null,
|
||||||
),
|
label: 'Free',
|
||||||
onPressed: () {
|
|
||||||
cropController.rotateLeft();
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
IconButton(
|
_AspectRatioButton(
|
||||||
icon: Icon(
|
cropController: cropController,
|
||||||
Icons.rotate_right,
|
aspectRatio: aspectRatio,
|
||||||
color: Theme.of(context).iconTheme.color,
|
ratio: 1.0,
|
||||||
),
|
label: '1:1',
|
||||||
onPressed: () {
|
),
|
||||||
cropController.rotateRight();
|
_AspectRatioButton(
|
||||||
},
|
cropController: cropController,
|
||||||
|
aspectRatio: aspectRatio,
|
||||||
|
ratio: 16.0 / 9.0,
|
||||||
|
label: '16:9',
|
||||||
|
),
|
||||||
|
_AspectRatioButton(
|
||||||
|
cropController: cropController,
|
||||||
|
aspectRatio: aspectRatio,
|
||||||
|
ratio: 3.0 / 2.0,
|
||||||
|
label: '3:2',
|
||||||
|
),
|
||||||
|
_AspectRatioButton(
|
||||||
|
cropController: cropController,
|
||||||
|
aspectRatio: aspectRatio,
|
||||||
|
ratio: 7.0 / 5.0,
|
||||||
|
label: '7:5',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
Row(
|
),
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
||||||
children: <Widget>[
|
|
||||||
_AspectRatioButton(
|
|
||||||
cropController: cropController,
|
|
||||||
aspectRatio: aspectRatio,
|
|
||||||
ratio: null,
|
|
||||||
label: 'Free',
|
|
||||||
),
|
|
||||||
_AspectRatioButton(
|
|
||||||
cropController: cropController,
|
|
||||||
aspectRatio: aspectRatio,
|
|
||||||
ratio: 1.0,
|
|
||||||
label: '1:1',
|
|
||||||
),
|
|
||||||
_AspectRatioButton(
|
|
||||||
cropController: cropController,
|
|
||||||
aspectRatio: aspectRatio,
|
|
||||||
ratio: 16.0 / 9.0,
|
|
||||||
label: '16:9',
|
|
||||||
),
|
|
||||||
_AspectRatioButton(
|
|
||||||
cropController: cropController,
|
|
||||||
aspectRatio: aspectRatio,
|
|
||||||
ratio: 3.0 / 2.0,
|
|
||||||
label: '3:2',
|
|
||||||
),
|
|
||||||
_AspectRatioButton(
|
|
||||||
cropController: cropController,
|
|
||||||
aspectRatio: aspectRatio,
|
|
||||||
ratio: 7.0 / 5.0,
|
|
||||||
label: '7:5',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
);
|
||||||
);
|
},
|
||||||
},
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,12 +86,16 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
|
|||||||
void handleAppPause() {
|
void handleAppPause() {
|
||||||
state = AppLifeCycleEnum.paused;
|
state = AppLifeCycleEnum.paused;
|
||||||
_wasPaused = true;
|
_wasPaused = true;
|
||||||
// Do not cancel backup if manual upload is in progress
|
|
||||||
if (_ref.read(backupProvider.notifier).backupProgress !=
|
if (_ref.read(authenticationProvider).isAuthenticated) {
|
||||||
BackUpProgressEnum.manualInProgress) {
|
// Do not cancel backup if manual upload is in progress
|
||||||
_ref.read(backupProvider.notifier).cancelBackup();
|
if (_ref.read(backupProvider.notifier).backupProgress !=
|
||||||
|
BackUpProgressEnum.manualInProgress) {
|
||||||
|
_ref.read(backupProvider.notifier).cancelBackup();
|
||||||
|
}
|
||||||
|
_ref.read(websocketProvider.notifier).disconnect();
|
||||||
}
|
}
|
||||||
_ref.read(websocketProvider.notifier).disconnect();
|
|
||||||
ImmichLogger().flush();
|
ImmichLogger().flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
133
readme_i18n/README_vi_VN.md
Normal file
133
readme_i18n/README_vi_VN.md
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
<p align="center">
|
||||||
|
<br/>
|
||||||
|
<a href="https://opensource.org/license/agpl-v3"><img src="https://img.shields.io/badge/License-AGPL_v3-blue.svg?color=3F51B5&style=for-the-badge&label=License&logoColor=000000&labelColor=ececec" alt="Giấy phép: AGPLv3"></a>
|
||||||
|
<a href="https://discord.immich.app">
|
||||||
|
<img src="https://img.shields.io/discord/979116623879368755.svg?label=Discord&logo=Discord&style=for-the-badge&logoColor=000000&labelColor=ececec" alt="Discord"/>
|
||||||
|
</a>
|
||||||
|
<br/>
|
||||||
|
<br/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="../design/immich-logo-stacked-light.svg" width="300" title="Đăng nhập bằng URL Tuỳ chỉnh">
|
||||||
|
</p>
|
||||||
|
<h3 align="center">Giải pháp quản lý ảnh và video tự lưu trữ hiệu suất cao</h3>
|
||||||
|
<br/>
|
||||||
|
<a href="https://immich.app">
|
||||||
|
<img src="../design/immich-screenshots.png" title="Ảnh chụp màn hình chính">
|
||||||
|
</a>
|
||||||
|
<br/>
|
||||||
|
<p align="center">
|
||||||
|
|
||||||
|
<a href="../README.md">English</a>
|
||||||
|
<a href="README_ca_ES.md">Català</a>
|
||||||
|
<a href="README_es_ES.md">Español</a>
|
||||||
|
<a href="README_fr_FR.md">Français</a>
|
||||||
|
<a href="README_it_IT.md">Italiano</a>
|
||||||
|
<a href="README_ja_JP.md">日本語</a>
|
||||||
|
<a href="README_ko_KR.md">한국어</a>
|
||||||
|
<a href="README_de_DE.md">Deutsch</a>
|
||||||
|
<a href="README_nl_NL.md">Nederlands</a>
|
||||||
|
<a href="README_tr_TR.md">Türkçe</a>
|
||||||
|
<a href="README_zh_CN.md">中文</a>
|
||||||
|
<a href="README_ru_RU.md">Русский</a>
|
||||||
|
<a href="README_pt_BR.md">Português Brasileiro</a>
|
||||||
|
<a href="README_sv_SE.md">Svenska</a>
|
||||||
|
<a href="README_ar_JO.md">العربية</a>
|
||||||
|
<a href="README_vi_VN.md">Tiếng Việt</a>
|
||||||
|
|
||||||
|
</p>
|
||||||
|
|
||||||
|
## Tuyên bố miễn trừ trách nhiệm
|
||||||
|
|
||||||
|
- ⚠️ Dự án đang được phát triển **rất tích cực**.
|
||||||
|
- ⚠️ Dự kiến sẽ có lỗi và thay đổi đột ngột.
|
||||||
|
- ⚠️ **Không sử dụng ứng dụng như là cách duy nhất để lưu trữ ảnh và video của bạn.**
|
||||||
|
- ⚠️ Luôn tuân thủ kế hoạch sao lưu [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) cho những bức ảnh và video quý giá của bạn!
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Bạn có thể tìm thấy tài liệu chính, bao gồm hướng dẫn cài đặt, tại https://immich.app/.
|
||||||
|
|
||||||
|
## Liên kết
|
||||||
|
|
||||||
|
- [Tài liệu](https://immich.app/docs)
|
||||||
|
- [Giới thiệu](https://immich.app/docs/overview/introduction)
|
||||||
|
- [Cài đặt](https://immich.app/docs/install/requirements)
|
||||||
|
- [Lộ trình](https://immich.app/roadmap)
|
||||||
|
- [Demo](#demo)
|
||||||
|
- [Tính năng](#Tính-năng)
|
||||||
|
- [Dịch thuật](https://immich.app/docs/developer/translations)
|
||||||
|
- [Đóng góp](https://immich.app/docs/overview/support-the-project)
|
||||||
|
|
||||||
|
## Demo
|
||||||
|
|
||||||
|
Truy cập bản demo [tại đây](https://demo.immich.app). Bản demo đang chạy trên máy ảo Oracle Free-tier ở Amsterdam với CPU ARM64 lõi tứ 2,4 GHz và RAM 24 GB.
|
||||||
|
|
||||||
|
Đối với ứng dụng di động, bạn có thể sử dụng `https://demo.immich.app/api` cho `Server Endpoint URL`
|
||||||
|
|
||||||
|
### Thông tin đăng nhập
|
||||||
|
|
||||||
|
| Email | Mật khẩu |
|
||||||
|
| --------------- | -------- |
|
||||||
|
| demo@immich.app | demo |
|
||||||
|
|
||||||
|
## Tính năng
|
||||||
|
|
||||||
|
| Tính năng | Mobile | Web |
|
||||||
|
| :--------------------------------------------------- | ------ | ----- |
|
||||||
|
| Tải lên và xem video, ảnh | Có | Có |
|
||||||
|
| Tự động sao lưu khi ứng dụng được mở | Có | N/A |
|
||||||
|
| Ngăn chặn sự trùng lặp nội dung | Có | Có |
|
||||||
|
| Album được chọn để sao lưu | Có | N/A |
|
||||||
|
| Tải ảnh và video xuống thiết bị cục bộ | Có | Có |
|
||||||
|
| Hỗ trợ nhiều người dùng | Có | Có |
|
||||||
|
| Album và Album được chia sẻ | Có | Có |
|
||||||
|
| Thanh cuộn có thể chà / kéo | Có | Có |
|
||||||
|
| Hỗ trợ định dạng raw | Có | Có |
|
||||||
|
| Xem metadata (EXIF, bản đồ) | Có | Có |
|
||||||
|
| Tìm kiếm theo metadata, đối tượng, khuôn mặt và CLIP | Có | Có |
|
||||||
|
| Chức năng quản trị (quản lý người dùng) | Không | Có |
|
||||||
|
| Sao lưu trong nền | Có | N/A |
|
||||||
|
| Cuộn ảo | Có | Có |
|
||||||
|
| Hỗ trợ OAuth | Có | Có |
|
||||||
|
| API Keys | N/A | Có |
|
||||||
|
| Sao lưu và phát lại Live Photo/Motion Photo | Có | Có |
|
||||||
|
| Hỗ trợ hiển thị hình ảnh 360 độ | Không | Có |
|
||||||
|
| Cấu trúc lưu trữ do người dùng xác định | Có | Có |
|
||||||
|
| Chia sẻ công khai | Có | Có |
|
||||||
|
| Lưu trữ và Yêu thích | Có | Có |
|
||||||
|
| Bản đồ toàn cầu | Có | Có |
|
||||||
|
| Chia sẻ đối tác | Có | Có |
|
||||||
|
| Nhận dạng khuôn mặt và phân cụm | Có | Có |
|
||||||
|
| Kỷ niệm (x năm trước) | Có | Có |
|
||||||
|
| Hỗ trợ ngoại tuyến | Có | Không |
|
||||||
|
| Thư viện chỉ đọc | Có | Có |
|
||||||
|
| Ảnh xếp chồng | Có | Có |
|
||||||
|
|
||||||
|
## Dịch thuật
|
||||||
|
|
||||||
|
Đọc thêm về dịch thuật [tại đây](https://immich.app/docs/developer/translations).
|
||||||
|
|
||||||
|
<a href="https://hosted.weblate.org/engage/immich/">
|
||||||
|
<img src="https://hosted.weblate.org/widget/immich/immich/multi-auto.svg" alt="Tình trạng dịch thuật" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
## Hoạt động của repository
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Lịch sử Đánh dấu sao
|
||||||
|
|
||||||
|
<a href="https://star-history.com/#immich-app/immich&Date">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date&theme=dark" />
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" />
|
||||||
|
<img alt="Biểu đồ Lịch sử Đánh dấu" src="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" width="100%" />
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
## Người đóng góp
|
||||||
|
|
||||||
|
<a href="https://github.com/alextran1502/immich/graphs/contributors">
|
||||||
|
<img src="https://contrib.rocks/image?repo=immich-app/immich" width="100%"/>
|
||||||
|
</a>
|
||||||
@@ -7,7 +7,18 @@ export interface ExifDuration {
|
|||||||
Scale?: number;
|
Scale?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TagsWithWrongTypes = 'FocalLength' | 'Duration' | 'Description' | 'ImageDescription' | 'RegionInfo';
|
type StringOrNumber = string | number;
|
||||||
|
|
||||||
|
type TagsWithWrongTypes =
|
||||||
|
| 'FocalLength'
|
||||||
|
| 'Duration'
|
||||||
|
| 'Description'
|
||||||
|
| 'ImageDescription'
|
||||||
|
| 'RegionInfo'
|
||||||
|
| 'TagsList'
|
||||||
|
| 'Keywords'
|
||||||
|
| 'HierarchicalSubject'
|
||||||
|
| 'ISO';
|
||||||
export interface ImmichTags extends Omit<Tags, TagsWithWrongTypes> {
|
export interface ImmichTags extends Omit<Tags, TagsWithWrongTypes> {
|
||||||
ContentIdentifier?: string;
|
ContentIdentifier?: string;
|
||||||
MotionPhoto?: number;
|
MotionPhoto?: number;
|
||||||
@@ -20,10 +31,14 @@ export interface ImmichTags extends Omit<Tags, TagsWithWrongTypes> {
|
|||||||
EmbeddedVideoType?: string;
|
EmbeddedVideoType?: string;
|
||||||
EmbeddedVideoFile?: BinaryField;
|
EmbeddedVideoFile?: BinaryField;
|
||||||
MotionPhotoVideo?: BinaryField;
|
MotionPhotoVideo?: BinaryField;
|
||||||
|
TagsList?: StringOrNumber[];
|
||||||
|
HierarchicalSubject?: StringOrNumber[];
|
||||||
|
Keywords?: StringOrNumber | StringOrNumber[];
|
||||||
|
ISO?: number | number[];
|
||||||
|
|
||||||
// Type is wrong, can also be number.
|
// Type is wrong, can also be number.
|
||||||
Description?: string | number;
|
Description?: StringOrNumber;
|
||||||
ImageDescription?: string | number;
|
ImageDescription?: StringOrNumber;
|
||||||
|
|
||||||
// Extended properties for image regions, such as faces
|
// Extended properties for image regions, such as faces
|
||||||
RegionInfo?: {
|
RegionInfo?: {
|
||||||
|
|||||||
@@ -316,7 +316,7 @@ describe(MetadataService.name, () => {
|
|||||||
|
|
||||||
it('should handle lists of numbers', async () => {
|
it('should handle lists of numbers', async () => {
|
||||||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||||
metadataMock.readTags.mockResolvedValue({ ISO: [160] as any });
|
metadataMock.readTags.mockResolvedValue({ ISO: [160] });
|
||||||
|
|
||||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||||
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]);
|
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]);
|
||||||
@@ -411,7 +411,7 @@ describe(MetadataService.name, () => {
|
|||||||
|
|
||||||
it('should extract tags from Keywords as a list with a number', async () => {
|
it('should extract tags from Keywords as a list with a number', async () => {
|
||||||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||||
metadataMock.readTags.mockResolvedValue({ Keywords: ['Parent', 2024] as any[] });
|
metadataMock.readTags.mockResolvedValue({ Keywords: ['Parent', 2024] });
|
||||||
tagMock.upsertValue.mockResolvedValue(tagStub.parent);
|
tagMock.upsertValue.mockResolvedValue(tagStub.parent);
|
||||||
|
|
||||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||||
@@ -467,6 +467,17 @@ describe(MetadataService.name, () => {
|
|||||||
expect(tagMock.upsertValue).toHaveBeenNthCalledWith(3, { userId: 'user-id', value: 'TagA', parent: undefined });
|
expect(tagMock.upsertValue).toHaveBeenNthCalledWith(3, { userId: 'user-id', value: 'TagA', parent: undefined });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should extract tags from HierarchicalSubject as a list with a number', async () => {
|
||||||
|
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||||
|
metadataMock.readTags.mockResolvedValue({ HierarchicalSubject: ['Parent', 2024] });
|
||||||
|
tagMock.upsertValue.mockResolvedValue(tagStub.parent);
|
||||||
|
|
||||||
|
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||||
|
|
||||||
|
expect(tagMock.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined });
|
||||||
|
expect(tagMock.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: '2024', parent: undefined });
|
||||||
|
});
|
||||||
|
|
||||||
it('should extract ignore / characters in a HierarchicalSubject tag', async () => {
|
it('should extract ignore / characters in a HierarchicalSubject tag', async () => {
|
||||||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||||
metadataMock.readTags.mockResolvedValue({ HierarchicalSubject: ['Mom/Dad'] });
|
metadataMock.readTags.mockResolvedValue({ HierarchicalSubject: ['Mom/Dad'] });
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { SystemConfigCore } from 'src/cores/system-config.core';
|
|||||||
import { OnEmit } from 'src/decorators';
|
import { OnEmit } from 'src/decorators';
|
||||||
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
||||||
import { AssetEntity } from 'src/entities/asset.entity';
|
import { AssetEntity } from 'src/entities/asset.entity';
|
||||||
|
import { ExifEntity } from 'src/entities/exif.entity';
|
||||||
import { PersonEntity } from 'src/entities/person.entity';
|
import { PersonEntity } from 'src/entities/person.entity';
|
||||||
import { AssetType, SourceType } from 'src/enum';
|
import { AssetType, SourceType } from 'src/enum';
|
||||||
import { IAlbumRepository } from 'src/interfaces/album.interface';
|
import { IAlbumRepository } from 'src/interfaces/album.interface';
|
||||||
@@ -236,7 +237,7 @@ export class MetadataService {
|
|||||||
const { dateTimeOriginal, localDateTime, timeZone, modifyDate } = this.getDates(asset, exifTags);
|
const { dateTimeOriginal, localDateTime, timeZone, modifyDate } = this.getDates(asset, exifTags);
|
||||||
const { latitude, longitude, country, state, city } = await this.getGeo(exifTags, reverseGeocoding);
|
const { latitude, longitude, country, state, city } = await this.getGeo(exifTags, reverseGeocoding);
|
||||||
|
|
||||||
const exifData = {
|
const exifData: Partial<ExifEntity> = {
|
||||||
assetId: asset.id,
|
assetId: asset.id,
|
||||||
|
|
||||||
// dates
|
// dates
|
||||||
@@ -264,7 +265,7 @@ export class MetadataService {
|
|||||||
make: exifTags.Make ?? null,
|
make: exifTags.Make ?? null,
|
||||||
model: exifTags.Model ?? null,
|
model: exifTags.Model ?? null,
|
||||||
fps: validate(Number.parseFloat(exifTags.VideoFrameRate!)),
|
fps: validate(Number.parseFloat(exifTags.VideoFrameRate!)),
|
||||||
iso: validate(exifTags.ISO),
|
iso: validate(exifTags.ISO) as number,
|
||||||
exposureTime: exifTags.ExposureTime ?? null,
|
exposureTime: exifTags.ExposureTime ?? null,
|
||||||
lensModel: exifTags.LensModel ?? null,
|
lensModel: exifTags.LensModel ?? null,
|
||||||
fNumber: validate(exifTags.FNumber),
|
fNumber: validate(exifTags.FNumber),
|
||||||
@@ -395,13 +396,13 @@ export class MetadataService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async applyTagList(asset: AssetEntity, exifTags: ImmichTags) {
|
private async applyTagList(asset: AssetEntity, exifTags: ImmichTags) {
|
||||||
const tags: Array<string | number> = [];
|
const tags: string[] = [];
|
||||||
if (exifTags.TagsList) {
|
if (exifTags.TagsList) {
|
||||||
tags.push(...exifTags.TagsList);
|
tags.push(...exifTags.TagsList.map(String));
|
||||||
} else if (exifTags.HierarchicalSubject) {
|
} else if (exifTags.HierarchicalSubject) {
|
||||||
tags.push(
|
tags.push(
|
||||||
...exifTags.HierarchicalSubject.map((tag) =>
|
...exifTags.HierarchicalSubject.map((tag) =>
|
||||||
tag
|
String(tag)
|
||||||
// convert | to /
|
// convert | to /
|
||||||
.replaceAll('/', '<PLACEHOLDER>')
|
.replaceAll('/', '<PLACEHOLDER>')
|
||||||
.replaceAll('|', '/')
|
.replaceAll('|', '/')
|
||||||
@@ -413,10 +414,10 @@ export class MetadataService {
|
|||||||
if (!Array.isArray(keywords)) {
|
if (!Array.isArray(keywords)) {
|
||||||
keywords = [keywords];
|
keywords = [keywords];
|
||||||
}
|
}
|
||||||
tags.push(...keywords);
|
tags.push(...keywords.map(String));
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = await upsertTags(this.tagRepository, { userId: asset.ownerId, tags: tags.map(String) });
|
const results = await upsertTags(this.tagRepository, { userId: asset.ownerId, tags });
|
||||||
await this.tagRepository.upsertAssetTags({ assetId: asset.id, tagIds: results.map((tag) => tag.id) });
|
await this.tagRepository.upsertAssetTags({ assetId: asset.id, tagIds: results.map((tag) => tag.id) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ interface Options {
|
|||||||
onEscape?: () => void;
|
onEscape?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls a function when a click occurs outside of the element, or when the escape key is pressed.
|
||||||
|
* @param node
|
||||||
|
* @param options Object containing onOutclick and onEscape functions
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
export function clickOutside(node: HTMLElement, options: Options = {}): ActionReturn {
|
export function clickOutside(node: HTMLElement, options: Options = {}): ActionReturn {
|
||||||
const { onOutclick, onEscape } = options;
|
const { onOutclick, onEscape } = options;
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,11 @@ export const contextMenuNavigation: Action<HTMLElement, Options> = (node, option
|
|||||||
};
|
};
|
||||||
|
|
||||||
const moveSelection = async (direction: 'up' | 'down', event: KeyboardEvent) => {
|
const moveSelection = async (direction: 'up' | 'down', event: KeyboardEvent) => {
|
||||||
const { selectionChanged, container, openDropdown } = options;
|
const { selectionChanged, container, openDropdown, isOpen } = options;
|
||||||
|
if (!isOpen) {
|
||||||
|
// reset the scroll position before opening the menu
|
||||||
|
container?.scrollTo({ top: 0 });
|
||||||
|
}
|
||||||
if (openDropdown) {
|
if (openDropdown) {
|
||||||
openDropdown(event);
|
openDropdown(event);
|
||||||
await tick();
|
await tick();
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ interface Options {
|
|||||||
onFocusOut?: (event: FocusEvent) => void;
|
onFocusOut?: (event: FocusEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls a function when focus leaves the element.
|
||||||
|
* @param node
|
||||||
|
* @param options Object containing onFocusOut function
|
||||||
|
*/
|
||||||
export function focusOutside(node: HTMLElement, options: Options = {}) {
|
export function focusOutside(node: HTMLElement, options: Options = {}) {
|
||||||
const { onFocusOut } = options;
|
const { onFocusOut } = options;
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/** Focus the given element when it is mounted. */
|
||||||
export const initInput = (element: HTMLInputElement) => {
|
export const initInput = (element: HTMLInputElement) => {
|
||||||
element.focus();
|
element.focus();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ type OnIntersectCallback = (entryOrElement: IntersectionObserverEntry | HTMLElem
|
|||||||
type OnSeparateCallback = (element: HTMLElement) => unknown;
|
type OnSeparateCallback = (element: HTMLElement) => unknown;
|
||||||
type IntersectionObserverActionProperties = {
|
type IntersectionObserverActionProperties = {
|
||||||
key?: string;
|
key?: string;
|
||||||
|
/** Function to execute when the element leaves the viewport */
|
||||||
onSeparate?: OnSeparateCallback;
|
onSeparate?: OnSeparateCallback;
|
||||||
|
/** Function to execute when the element enters the viewport */
|
||||||
onIntersect?: OnIntersectCallback;
|
onIntersect?: OnIntersectCallback;
|
||||||
|
|
||||||
root?: Element | Document | null;
|
root?: Element | Document | null;
|
||||||
@@ -112,6 +114,12 @@ function _intersectionObserver(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Monitors an element's visibility in the viewport and calls functions when it enters or leaves (based on a threshold).
|
||||||
|
* @param element
|
||||||
|
* @param properties One or multiple configurations for the IntersectionObserver(s)
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
export function intersectionObserver(
|
export function intersectionObserver(
|
||||||
element: HTMLElement,
|
element: HTMLElement,
|
||||||
properties: IntersectionObserverActionProperties | IntersectionObserverActionProperties[],
|
properties: IntersectionObserverActionProperties | IntersectionObserverActionProperties[],
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { shortcuts } from '$lib/actions/shortcut';
|
import { shortcuts } from '$lib/actions/shortcut';
|
||||||
import type { Action } from 'svelte/action';
|
import type { Action } from 'svelte/action';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enables keyboard navigation (up and down arrows) for a list of elements.
|
||||||
|
* @param node Element which listens for keyboard events
|
||||||
|
* @param container Element containing the list of elements
|
||||||
|
*/
|
||||||
export const listNavigation: Action<HTMLElement, HTMLElement> = (node, container: HTMLElement) => {
|
export const listNavigation: Action<HTMLElement, HTMLElement> = (node, container: HTMLElement) => {
|
||||||
const moveFocus = (direction: 'up' | 'down') => {
|
const moveFocus = (direction: 'up' | 'down') => {
|
||||||
const children = Array.from(container?.children);
|
const children = Array.from(container?.children);
|
||||||
|
|||||||
@@ -10,11 +10,16 @@ export type Shortcut = {
|
|||||||
|
|
||||||
export type ShortcutOptions<T = HTMLElement> = {
|
export type ShortcutOptions<T = HTMLElement> = {
|
||||||
shortcut: Shortcut;
|
shortcut: Shortcut;
|
||||||
|
/** If true, the event handler will not execute if the event comes from an input field */
|
||||||
ignoreInputFields?: boolean;
|
ignoreInputFields?: boolean;
|
||||||
onShortcut: (event: KeyboardEvent & { currentTarget: T }) => unknown;
|
onShortcut: (event: KeyboardEvent & { currentTarget: T }) => unknown;
|
||||||
preventDefault?: boolean;
|
preventDefault?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Determines whether an event should be ignored. The event will be ignored if:
|
||||||
|
* - The element dispatching the event is not the same as the element which the event listener is attached to
|
||||||
|
* - The element dispatching the event is an input field
|
||||||
|
*/
|
||||||
export const shouldIgnoreEvent = (event: KeyboardEvent | ClipboardEvent): boolean => {
|
export const shouldIgnoreEvent = (event: KeyboardEvent | ClipboardEvent): boolean => {
|
||||||
if (event.target === event.currentTarget) {
|
if (event.target === event.currentTarget) {
|
||||||
return false;
|
return false;
|
||||||
@@ -33,6 +38,7 @@ export const matchesShortcut = (event: KeyboardEvent, shortcut: Shortcut) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Bind a single keyboard shortcut to node. */
|
||||||
export const shortcut = <T extends HTMLElement>(
|
export const shortcut = <T extends HTMLElement>(
|
||||||
node: T,
|
node: T,
|
||||||
option: ShortcutOptions<T>,
|
option: ShortcutOptions<T>,
|
||||||
@@ -47,6 +53,7 @@ export const shortcut = <T extends HTMLElement>(
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Binds multiple keyboard shortcuts to node */
|
||||||
export const shortcuts = <T extends HTMLElement>(
|
export const shortcuts = <T extends HTMLElement>(
|
||||||
node: T,
|
node: T,
|
||||||
options: ShortcutOptions<T>[],
|
options: ShortcutOptions<T>[],
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { decodeBase64 } from '$lib/utils';
|
import { decodeBase64 } from '$lib/utils';
|
||||||
import { thumbHashToRGBA } from 'thumbhash';
|
import { thumbHashToRGBA } from 'thumbhash';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a thumbnail onto a canvas from a base64 encoded hash.
|
||||||
|
* @param canvas
|
||||||
|
* @param param1 object containing the base64 encoded hash (base64Thumbhash: yourString)
|
||||||
|
*/
|
||||||
export function thumbhash(canvas: HTMLCanvasElement, { base64ThumbHash }: { base64ThumbHash: string }) {
|
export function thumbhash(canvas: HTMLCanvasElement, { base64ThumbHash }: { base64ThumbHash: string }) {
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (ctx) {
|
if (ctx) {
|
||||||
|
|||||||
@@ -109,7 +109,13 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if isOwned}
|
{#if isOwned}
|
||||||
<ButtonContextMenu icon={mdiDotsVertical} size="20" title={$t('options')}>
|
<ButtonContextMenu
|
||||||
|
icon={mdiDotsVertical}
|
||||||
|
size="20"
|
||||||
|
title={$t('options')}
|
||||||
|
direction="right"
|
||||||
|
align="top-left"
|
||||||
|
>
|
||||||
{#if role === AlbumUserRole.Viewer}
|
{#if role === AlbumUserRole.Viewer}
|
||||||
<MenuOption onClick={() => handleSetReadonly(user, AlbumUserRole.Editor)} text={$t('allow_edits')} />
|
<MenuOption onClick={() => handleSetReadonly(user, AlbumUserRole.Editor)} text={$t('allow_edits')} />
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -186,13 +186,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if reaction.user.id === user.id || albumOwnerId === user.id}
|
{#if reaction.user.id === user.id || albumOwnerId === user.id}
|
||||||
<div class="mr-4">
|
<div class="mr-4">
|
||||||
<ButtonContextMenu
|
<ButtonContextMenu icon={mdiDotsVertical} title={$t('comment_options')} size="16">
|
||||||
icon={mdiDotsVertical}
|
|
||||||
title={$t('comment_options')}
|
|
||||||
align="top-right"
|
|
||||||
direction="left"
|
|
||||||
size="16"
|
|
||||||
>
|
|
||||||
<MenuOption
|
<MenuOption
|
||||||
activeColor="bg-red-200"
|
activeColor="bg-red-200"
|
||||||
icon={mdiDeleteOutline}
|
icon={mdiDeleteOutline}
|
||||||
@@ -239,13 +233,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if reaction.user.id === user.id || albumOwnerId === user.id}
|
{#if reaction.user.id === user.id || albumOwnerId === user.id}
|
||||||
<div class="mr-4">
|
<div class="mr-4">
|
||||||
<ButtonContextMenu
|
<ButtonContextMenu icon={mdiDotsVertical} title={$t('reaction_options')} size="16">
|
||||||
icon={mdiDotsVertical}
|
|
||||||
title={$t('reaction_options')}
|
|
||||||
align="top-right"
|
|
||||||
direction="left"
|
|
||||||
size="16"
|
|
||||||
>
|
|
||||||
<MenuOption
|
<MenuOption
|
||||||
activeColor="bg-red-200"
|
activeColor="bg-red-200"
|
||||||
icon={mdiDeleteOutline}
|
icon={mdiDeleteOutline}
|
||||||
|
|||||||
@@ -128,7 +128,7 @@
|
|||||||
{#if isOwner}
|
{#if isOwner}
|
||||||
<DeleteAction {asset} {onAction} />
|
<DeleteAction {asset} {onAction} />
|
||||||
|
|
||||||
<ButtonContextMenu direction="left" align="top-right" color="opaque" title={$t('more')} icon={mdiDotsVertical}>
|
<ButtonContextMenu color="opaque" title={$t('more')} icon={mdiDotsVertical}>
|
||||||
{#if showSlideshow}
|
{#if showSlideshow}
|
||||||
<MenuOption icon={mdiPresentationPlay} text={$t('slideshow')} onClick={onPlaySlideshow} />
|
<MenuOption icon={mdiPresentationPlay} text={$t('slideshow')} onClick={onPlaySlideshow} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -67,6 +67,8 @@
|
|||||||
size="20"
|
size="20"
|
||||||
icon={mdiDotsVertical}
|
icon={mdiDotsVertical}
|
||||||
title={$t('show_person_options')}
|
title={$t('show_person_options')}
|
||||||
|
direction="right"
|
||||||
|
align="top-left"
|
||||||
>
|
>
|
||||||
<MenuOption onClick={onHidePerson} icon={mdiEyeOffOutline} text={$t('hide_person')} />
|
<MenuOption onClick={onHidePerson} icon={mdiEyeOffOutline} text={$t('hide_person')} />
|
||||||
<MenuOption onClick={onChangeName} icon={mdiAccountEditOutline} text={$t('change_name')} />
|
<MenuOption onClick={onChangeName} icon={mdiAccountEditOutline} text={$t('change_name')} />
|
||||||
|
|||||||
@@ -237,7 +237,7 @@
|
|||||||
|
|
||||||
<FavoriteAction removeFavorite={isAllFavorite} onFavorite={handleUpdate} />
|
<FavoriteAction removeFavorite={isAllFavorite} onFavorite={handleUpdate} />
|
||||||
|
|
||||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('add')}>
|
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||||
<DownloadAction menuItem />
|
<DownloadAction menuItem />
|
||||||
<ChangeDate menuItem />
|
<ChangeDate menuItem />
|
||||||
<ChangeLocation menuItem />
|
<ChangeLocation menuItem />
|
||||||
|
|||||||
@@ -20,11 +20,11 @@
|
|||||||
/**
|
/**
|
||||||
* The alignment of the context menu relative to the button.
|
* The alignment of the context menu relative to the button.
|
||||||
*/
|
*/
|
||||||
export let align: Align = 'top-left';
|
export let align: Align = 'top-right';
|
||||||
/**
|
/**
|
||||||
* The direction in which the context menu should open.
|
* The direction in which the context menu should open.
|
||||||
*/
|
*/
|
||||||
export let direction: 'left' | 'right' = 'right';
|
export let direction: 'left' | 'right' = 'left';
|
||||||
export let color: Color = 'transparent';
|
export let color: Color = 'transparent';
|
||||||
export let size: string | undefined = undefined;
|
export let size: string | undefined = undefined;
|
||||||
export let padding: Padding | undefined = undefined;
|
export let padding: Padding | undefined = undefined;
|
||||||
|
|||||||
@@ -18,25 +18,26 @@
|
|||||||
let left: number;
|
let left: number;
|
||||||
let top: number;
|
let top: number;
|
||||||
|
|
||||||
// We need to bind clientHeight since the bounding box may return a height
|
|
||||||
// of zero when starting the 'slide' animation.
|
|
||||||
let height: number;
|
|
||||||
|
|
||||||
$: {
|
$: {
|
||||||
if (menuElement) {
|
if (menuElement) {
|
||||||
const rect = menuElement.getBoundingClientRect();
|
const rect = menuElement.getBoundingClientRect();
|
||||||
const directionWidth = direction === 'left' ? rect.width : 0;
|
const directionWidth = direction === 'left' ? rect.width : 0;
|
||||||
const menuHeight = Math.min(menuElement.clientHeight, height) || 0;
|
const menuHeight = menuElement.clientHeight || 0;
|
||||||
|
|
||||||
left = Math.min(window.innerWidth - rect.width, x - directionWidth);
|
const calcLeft = Math.min(window.innerWidth - rect.width, x - directionWidth);
|
||||||
|
left = Math.max(0, calcLeft);
|
||||||
top = Math.min(window.innerHeight - menuHeight, y);
|
top = Math.min(window.innerHeight - menuHeight, y);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
bind:clientHeight={height}
|
class="fixed z-10 overflow-hidden rounded-lg duration-[250ms] ease-in {isVisible
|
||||||
class="fixed z-10 min-w-[200px] w-max max-w-[300px] overflow-hidden rounded-lg shadow-lg"
|
? 'shadow-lg transition-shadow'
|
||||||
|
: 'shadow-none transition-none'}"
|
||||||
|
class:shadow-none={!isVisible}
|
||||||
|
class:shadow-lg={isVisible}
|
||||||
|
class:transition-none={!isVisible}
|
||||||
style:left="{left}px"
|
style:left="{left}px"
|
||||||
style:top="{top}px"
|
style:top="{top}px"
|
||||||
transition:slide={{ duration: 250, easing: quintOut }}
|
transition:slide={{ duration: 250, easing: quintOut }}
|
||||||
@@ -48,9 +49,9 @@
|
|||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
aria-labelledby={ariaLabelledBy}
|
aria-labelledby={ariaLabelledBy}
|
||||||
bind:this={menuElement}
|
bind:this={menuElement}
|
||||||
class:max-h-[100vh]={isVisible}
|
class="flex flex-col transition-all duration-[250ms] ease-in-out outline-none immich-scrollbar bg-slate-100 relative min-w-[200px] max-w-[200px] sm:max-w-[256px] rounded-lg {isVisible
|
||||||
class:max-h-0={!isVisible}
|
? 'translate-x-0 max-h-dvh overflow-y-auto'
|
||||||
class="flex flex-col transition-all duration-[250ms] ease-in-out outline-none"
|
: `${direction === 'left' ? 'translate-x-28' : '-translate-x-28'} max-h-0 overflow-y-hidden`}"
|
||||||
role="menu"
|
role="menu"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -33,7 +33,9 @@
|
|||||||
role="menuitem"
|
role="menuitem"
|
||||||
>
|
>
|
||||||
{#if icon}
|
{#if icon}
|
||||||
<Icon path={icon} ariaHidden={true} size="18" />
|
<div class="flex-none">
|
||||||
|
<Icon path={icon} ariaHidden={true} size="18" />
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div>
|
<div>
|
||||||
{text}
|
{text}
|
||||||
|
|||||||
@@ -107,6 +107,8 @@
|
|||||||
size="24"
|
size="24"
|
||||||
padding="3"
|
padding="3"
|
||||||
hideContent
|
hideContent
|
||||||
|
direction="right"
|
||||||
|
align="top-left"
|
||||||
>
|
>
|
||||||
<SharedLinkEdit menuItem {onEdit} />
|
<SharedLinkEdit menuItem {onEdit} />
|
||||||
<SharedLinkCopy menuItem {link} />
|
<SharedLinkCopy menuItem {link} />
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
<AddToAlbum shared />
|
<AddToAlbum shared />
|
||||||
</ButtonContextMenu>
|
</ButtonContextMenu>
|
||||||
<FavoriteAction removeFavorite={isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} />
|
<FavoriteAction removeFavorite={isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} />
|
||||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('add')}>
|
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||||
<DownloadAction menuItem />
|
<DownloadAction menuItem />
|
||||||
<DeleteAssets menuItem onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} />
|
<DeleteAssets menuItem onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} />
|
||||||
</ButtonContextMenu>
|
</ButtonContextMenu>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
{#if $isMultiSelectState}
|
{#if $isMultiSelectState}
|
||||||
<AssetSelectControlBar assets={$selectedAssets} clearSelect={clearMultiselect}>
|
<AssetSelectControlBar assets={$selectedAssets} clearSelect={clearMultiselect}>
|
||||||
<CreateSharedLink />
|
<CreateSharedLink />
|
||||||
<ButtonContextMenu icon={mdiPlus} title={$t('add')}>
|
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
|
||||||
<AddToAlbum />
|
<AddToAlbum />
|
||||||
<AddToAlbum shared />
|
<AddToAlbum shared />
|
||||||
</ButtonContextMenu>
|
</ButtonContextMenu>
|
||||||
|
|||||||
@@ -385,7 +385,7 @@
|
|||||||
<AddToAlbum shared />
|
<AddToAlbum shared />
|
||||||
</ButtonContextMenu>
|
</ButtonContextMenu>
|
||||||
<FavoriteAction removeFavorite={isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} />
|
<FavoriteAction removeFavorite={isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} />
|
||||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('add')}>
|
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||||
<DownloadAction menuItem filename="{person.name || 'immich'}.zip" />
|
<DownloadAction menuItem filename="{person.name || 'immich'}.zip" />
|
||||||
<MenuOption
|
<MenuOption
|
||||||
icon={mdiAccountMultipleCheckOutline}
|
icon={mdiAccountMultipleCheckOutline}
|
||||||
|
|||||||
@@ -235,7 +235,7 @@
|
|||||||
</ButtonContextMenu>
|
</ButtonContextMenu>
|
||||||
<FavoriteAction removeFavorite={isAllFavorite} onFavorite={triggerAssetUpdate} />
|
<FavoriteAction removeFavorite={isAllFavorite} onFavorite={triggerAssetUpdate} />
|
||||||
|
|
||||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('add')}>
|
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||||
<DownloadAction menuItem />
|
<DownloadAction menuItem />
|
||||||
<ChangeDate menuItem />
|
<ChangeDate menuItem />
|
||||||
<ChangeLocation menuItem />
|
<ChangeLocation menuItem />
|
||||||
|
|||||||
@@ -285,14 +285,7 @@
|
|||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td class=" text-ellipsis px-4 text-sm">
|
<td class=" text-ellipsis px-4 text-sm">
|
||||||
<ButtonContextMenu
|
<ButtonContextMenu color="primary" size="16" icon={mdiDotsVertical} title={$t('library_options')}>
|
||||||
align="top-right"
|
|
||||||
direction="left"
|
|
||||||
color="primary"
|
|
||||||
size="16"
|
|
||||||
icon={mdiDotsVertical}
|
|
||||||
title={$t('library_options')}
|
|
||||||
>
|
|
||||||
<MenuOption onClick={() => onScanClicked(library)} text={$t('scan_library')} />
|
<MenuOption onClick={() => onScanClicked(library)} text={$t('scan_library')} />
|
||||||
<hr />
|
<hr />
|
||||||
<MenuOption onClick={() => onRenameClicked(index)} text={$t('rename')} />
|
<MenuOption onClick={() => onRenameClicked(index)} text={$t('rename')} />
|
||||||
|
|||||||
Reference in New Issue
Block a user