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_sv_SE.md">Svenska</a>
|
||||
<a href="readme_i18n/README_ar_JO.md">العربية</a>
|
||||
<a href="readme_i18n/README_vi_VN.md">Tiếng Việt</a>
|
||||
|
||||
</p>
|
||||
|
||||
|
||||
@@ -333,7 +333,11 @@ You may need to add mount points or docker volumes for the following internal co
|
||||
- `immich-machine-learning:/.cache`
|
||||
- `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`.
|
||||
|
||||
|
||||
@@ -401,7 +401,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 175;
|
||||
CURRENT_PROJECT_VERSION = 176;
|
||||
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 = 175;
|
||||
CURRENT_PROJECT_VERSION = 176;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@@ -571,7 +571,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 175;
|
||||
CURRENT_PROJECT_VERSION = 176;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
|
||||
@@ -58,11 +58,11 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.115.0</string>
|
||||
<string>1.116.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>175</string>
|
||||
<string>176</string>
|
||||
<key>FLTEnableImpeller</key>
|
||||
<true/>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
|
||||
@@ -51,107 +51,109 @@ class CropImagePage extends HookWidget {
|
||||
],
|
||||
),
|
||||
backgroundColor: context.scaffoldBackgroundColor,
|
||||
body: LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.only(top: 20),
|
||||
width: constraints.maxWidth * 0.9,
|
||||
height: constraints.maxHeight * 0.6,
|
||||
child: CropImage(
|
||||
controller: cropController,
|
||||
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),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.only(top: 20),
|
||||
width: constraints.maxWidth * 0.9,
|
||||
height: constraints.maxHeight * 0.6,
|
||||
child: CropImage(
|
||||
controller: cropController,
|
||||
image: image,
|
||||
gridColor: Colors.white,
|
||||
),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 20,
|
||||
right: 20,
|
||||
bottom: 10,
|
||||
),
|
||||
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(
|
||||
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(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.rotate_left,
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
onPressed: () {
|
||||
cropController.rotateLeft();
|
||||
},
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: <Widget>[
|
||||
_AspectRatioButton(
|
||||
cropController: cropController,
|
||||
aspectRatio: aspectRatio,
|
||||
ratio: null,
|
||||
label: 'Free',
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.rotate_right,
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
onPressed: () {
|
||||
cropController.rotateRight();
|
||||
},
|
||||
_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',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
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() {
|
||||
state = AppLifeCycleEnum.paused;
|
||||
_wasPaused = true;
|
||||
// Do not cancel backup if manual upload is in progress
|
||||
if (_ref.read(backupProvider.notifier).backupProgress !=
|
||||
BackUpProgressEnum.manualInProgress) {
|
||||
_ref.read(backupProvider.notifier).cancelBackup();
|
||||
|
||||
if (_ref.read(authenticationProvider).isAuthenticated) {
|
||||
// Do not cancel backup if manual upload is in progress
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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> {
|
||||
ContentIdentifier?: string;
|
||||
MotionPhoto?: number;
|
||||
@@ -20,10 +31,14 @@ export interface ImmichTags extends Omit<Tags, TagsWithWrongTypes> {
|
||||
EmbeddedVideoType?: string;
|
||||
EmbeddedVideoFile?: BinaryField;
|
||||
MotionPhotoVideo?: BinaryField;
|
||||
TagsList?: StringOrNumber[];
|
||||
HierarchicalSubject?: StringOrNumber[];
|
||||
Keywords?: StringOrNumber | StringOrNumber[];
|
||||
ISO?: number | number[];
|
||||
|
||||
// Type is wrong, can also be number.
|
||||
Description?: string | number;
|
||||
ImageDescription?: string | number;
|
||||
Description?: StringOrNumber;
|
||||
ImageDescription?: StringOrNumber;
|
||||
|
||||
// Extended properties for image regions, such as faces
|
||||
RegionInfo?: {
|
||||
|
||||
@@ -316,7 +316,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should handle lists of numbers', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||
metadataMock.readTags.mockResolvedValue({ ISO: [160] as any });
|
||||
metadataMock.readTags.mockResolvedValue({ ISO: [160] });
|
||||
|
||||
await sut.handleMetadataExtraction({ id: 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 () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||
metadataMock.readTags.mockResolvedValue({ Keywords: ['Parent', 2024] as any[] });
|
||||
metadataMock.readTags.mockResolvedValue({ Keywords: ['Parent', 2024] });
|
||||
tagMock.upsertValue.mockResolvedValue(tagStub.parent);
|
||||
|
||||
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 });
|
||||
});
|
||||
|
||||
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 () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||
metadataMock.readTags.mockResolvedValue({ HierarchicalSubject: ['Mom/Dad'] });
|
||||
|
||||
@@ -11,6 +11,7 @@ import { SystemConfigCore } from 'src/cores/system-config.core';
|
||||
import { OnEmit } from 'src/decorators';
|
||||
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
||||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import { ExifEntity } from 'src/entities/exif.entity';
|
||||
import { PersonEntity } from 'src/entities/person.entity';
|
||||
import { AssetType, SourceType } from 'src/enum';
|
||||
import { IAlbumRepository } from 'src/interfaces/album.interface';
|
||||
@@ -236,7 +237,7 @@ export class MetadataService {
|
||||
const { dateTimeOriginal, localDateTime, timeZone, modifyDate } = this.getDates(asset, exifTags);
|
||||
const { latitude, longitude, country, state, city } = await this.getGeo(exifTags, reverseGeocoding);
|
||||
|
||||
const exifData = {
|
||||
const exifData: Partial<ExifEntity> = {
|
||||
assetId: asset.id,
|
||||
|
||||
// dates
|
||||
@@ -264,7 +265,7 @@ export class MetadataService {
|
||||
make: exifTags.Make ?? null,
|
||||
model: exifTags.Model ?? null,
|
||||
fps: validate(Number.parseFloat(exifTags.VideoFrameRate!)),
|
||||
iso: validate(exifTags.ISO),
|
||||
iso: validate(exifTags.ISO) as number,
|
||||
exposureTime: exifTags.ExposureTime ?? null,
|
||||
lensModel: exifTags.LensModel ?? null,
|
||||
fNumber: validate(exifTags.FNumber),
|
||||
@@ -395,13 +396,13 @@ export class MetadataService {
|
||||
}
|
||||
|
||||
private async applyTagList(asset: AssetEntity, exifTags: ImmichTags) {
|
||||
const tags: Array<string | number> = [];
|
||||
const tags: string[] = [];
|
||||
if (exifTags.TagsList) {
|
||||
tags.push(...exifTags.TagsList);
|
||||
tags.push(...exifTags.TagsList.map(String));
|
||||
} else if (exifTags.HierarchicalSubject) {
|
||||
tags.push(
|
||||
...exifTags.HierarchicalSubject.map((tag) =>
|
||||
tag
|
||||
String(tag)
|
||||
// convert | to /
|
||||
.replaceAll('/', '<PLACEHOLDER>')
|
||||
.replaceAll('|', '/')
|
||||
@@ -413,10 +414,10 @@ export class MetadataService {
|
||||
if (!Array.isArray(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) });
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,12 @@ interface Options {
|
||||
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 {
|
||||
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 { 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) {
|
||||
openDropdown(event);
|
||||
await tick();
|
||||
|
||||
@@ -2,6 +2,11 @@ interface Options {
|
||||
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 = {}) {
|
||||
const { onFocusOut } = options;
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/** Focus the given element when it is mounted. */
|
||||
export const initInput = (element: HTMLInputElement) => {
|
||||
element.focus();
|
||||
};
|
||||
|
||||
@@ -13,7 +13,9 @@ type OnIntersectCallback = (entryOrElement: IntersectionObserverEntry | HTMLElem
|
||||
type OnSeparateCallback = (element: HTMLElement) => unknown;
|
||||
type IntersectionObserverActionProperties = {
|
||||
key?: string;
|
||||
/** Function to execute when the element leaves the viewport */
|
||||
onSeparate?: OnSeparateCallback;
|
||||
/** Function to execute when the element enters the viewport */
|
||||
onIntersect?: OnIntersectCallback;
|
||||
|
||||
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(
|
||||
element: HTMLElement,
|
||||
properties: IntersectionObserverActionProperties | IntersectionObserverActionProperties[],
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
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) => {
|
||||
const moveFocus = (direction: 'up' | 'down') => {
|
||||
const children = Array.from(container?.children);
|
||||
|
||||
@@ -10,11 +10,16 @@ export type Shortcut = {
|
||||
|
||||
export type ShortcutOptions<T = HTMLElement> = {
|
||||
shortcut: Shortcut;
|
||||
/** If true, the event handler will not execute if the event comes from an input field */
|
||||
ignoreInputFields?: boolean;
|
||||
onShortcut: (event: KeyboardEvent & { currentTarget: T }) => unknown;
|
||||
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 => {
|
||||
if (event.target === event.currentTarget) {
|
||||
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>(
|
||||
node: 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>(
|
||||
node: T,
|
||||
options: ShortcutOptions<T>[],
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { decodeBase64 } from '$lib/utils';
|
||||
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 }) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
|
||||
@@ -109,7 +109,13 @@
|
||||
{/if}
|
||||
</div>
|
||||
{#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}
|
||||
<MenuOption onClick={() => handleSetReadonly(user, AlbumUserRole.Editor)} text={$t('allow_edits')} />
|
||||
{:else}
|
||||
|
||||
@@ -186,13 +186,7 @@
|
||||
{/if}
|
||||
{#if reaction.user.id === user.id || albumOwnerId === user.id}
|
||||
<div class="mr-4">
|
||||
<ButtonContextMenu
|
||||
icon={mdiDotsVertical}
|
||||
title={$t('comment_options')}
|
||||
align="top-right"
|
||||
direction="left"
|
||||
size="16"
|
||||
>
|
||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('comment_options')} size="16">
|
||||
<MenuOption
|
||||
activeColor="bg-red-200"
|
||||
icon={mdiDeleteOutline}
|
||||
@@ -239,13 +233,7 @@
|
||||
{/if}
|
||||
{#if reaction.user.id === user.id || albumOwnerId === user.id}
|
||||
<div class="mr-4">
|
||||
<ButtonContextMenu
|
||||
icon={mdiDotsVertical}
|
||||
title={$t('reaction_options')}
|
||||
align="top-right"
|
||||
direction="left"
|
||||
size="16"
|
||||
>
|
||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('reaction_options')} size="16">
|
||||
<MenuOption
|
||||
activeColor="bg-red-200"
|
||||
icon={mdiDeleteOutline}
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
{#if isOwner}
|
||||
<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}
|
||||
<MenuOption icon={mdiPresentationPlay} text={$t('slideshow')} onClick={onPlaySlideshow} />
|
||||
{/if}
|
||||
|
||||
@@ -67,6 +67,8 @@
|
||||
size="20"
|
||||
icon={mdiDotsVertical}
|
||||
title={$t('show_person_options')}
|
||||
direction="right"
|
||||
align="top-left"
|
||||
>
|
||||
<MenuOption onClick={onHidePerson} icon={mdiEyeOffOutline} text={$t('hide_person')} />
|
||||
<MenuOption onClick={onChangeName} icon={mdiAccountEditOutline} text={$t('change_name')} />
|
||||
|
||||
@@ -237,7 +237,7 @@
|
||||
|
||||
<FavoriteAction removeFavorite={isAllFavorite} onFavorite={handleUpdate} />
|
||||
|
||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('add')}>
|
||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||
<DownloadAction menuItem />
|
||||
<ChangeDate menuItem />
|
||||
<ChangeLocation menuItem />
|
||||
|
||||
@@ -20,11 +20,11 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export let direction: 'left' | 'right' = 'right';
|
||||
export let direction: 'left' | 'right' = 'left';
|
||||
export let color: Color = 'transparent';
|
||||
export let size: string | undefined = undefined;
|
||||
export let padding: Padding | undefined = undefined;
|
||||
|
||||
@@ -18,25 +18,26 @@
|
||||
let left: 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) {
|
||||
const rect = menuElement.getBoundingClientRect();
|
||||
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);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:clientHeight={height}
|
||||
class="fixed z-10 min-w-[200px] w-max max-w-[300px] overflow-hidden rounded-lg shadow-lg"
|
||||
class="fixed z-10 overflow-hidden rounded-lg duration-[250ms] ease-in {isVisible
|
||||
? '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:top="{top}px"
|
||||
transition:slide={{ duration: 250, easing: quintOut }}
|
||||
@@ -48,9 +49,9 @@
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
bind:this={menuElement}
|
||||
class:max-h-[100vh]={isVisible}
|
||||
class:max-h-0={!isVisible}
|
||||
class="flex flex-col transition-all duration-[250ms] ease-in-out outline-none"
|
||||
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
|
||||
? 'translate-x-0 max-h-dvh overflow-y-auto'
|
||||
: `${direction === 'left' ? 'translate-x-28' : '-translate-x-28'} max-h-0 overflow-y-hidden`}"
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
>
|
||||
|
||||
@@ -33,7 +33,9 @@
|
||||
role="menuitem"
|
||||
>
|
||||
{#if icon}
|
||||
<Icon path={icon} ariaHidden={true} size="18" />
|
||||
<div class="flex-none">
|
||||
<Icon path={icon} ariaHidden={true} size="18" />
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
{text}
|
||||
|
||||
@@ -107,6 +107,8 @@
|
||||
size="24"
|
||||
padding="3"
|
||||
hideContent
|
||||
direction="right"
|
||||
align="top-left"
|
||||
>
|
||||
<SharedLinkEdit menuItem {onEdit} />
|
||||
<SharedLinkCopy menuItem {link} />
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
<AddToAlbum shared />
|
||||
</ButtonContextMenu>
|
||||
<FavoriteAction removeFavorite={isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} />
|
||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('add')}>
|
||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||
<DownloadAction menuItem />
|
||||
<DeleteAssets menuItem onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} />
|
||||
</ButtonContextMenu>
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
{#if $isMultiSelectState}
|
||||
<AssetSelectControlBar assets={$selectedAssets} clearSelect={clearMultiselect}>
|
||||
<CreateSharedLink />
|
||||
<ButtonContextMenu icon={mdiPlus} title={$t('add')}>
|
||||
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
|
||||
<AddToAlbum />
|
||||
<AddToAlbum shared />
|
||||
</ButtonContextMenu>
|
||||
|
||||
@@ -385,7 +385,7 @@
|
||||
<AddToAlbum shared />
|
||||
</ButtonContextMenu>
|
||||
<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" />
|
||||
<MenuOption
|
||||
icon={mdiAccountMultipleCheckOutline}
|
||||
|
||||
@@ -235,7 +235,7 @@
|
||||
</ButtonContextMenu>
|
||||
<FavoriteAction removeFavorite={isAllFavorite} onFavorite={triggerAssetUpdate} />
|
||||
|
||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('add')}>
|
||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||
<DownloadAction menuItem />
|
||||
<ChangeDate menuItem />
|
||||
<ChangeLocation menuItem />
|
||||
|
||||
@@ -285,14 +285,7 @@
|
||||
</td>
|
||||
|
||||
<td class=" text-ellipsis px-4 text-sm">
|
||||
<ButtonContextMenu
|
||||
align="top-right"
|
||||
direction="left"
|
||||
color="primary"
|
||||
size="16"
|
||||
icon={mdiDotsVertical}
|
||||
title={$t('library_options')}
|
||||
>
|
||||
<ButtonContextMenu color="primary" size="16" icon={mdiDotsVertical} title={$t('library_options')}>
|
||||
<MenuOption onClick={() => onScanClicked(library)} text={$t('scan_library')} />
|
||||
<hr />
|
||||
<MenuOption onClick={() => onRenameClicked(index)} text={$t('rename')} />
|
||||
|
||||
Reference in New Issue
Block a user