Compare commits

...

9 Commits

Author SHA1 Message Date
ben-basten
e628e6a807 feat(web): scrollable context menus 2024-09-26 23:13:46 -04:00
Alex
971ba63447 fix(mobile): uninitialize provider causes unable to logging in (#12970)
fix(mobile): use uninitialize provider
2024-09-27 09:40:55 +07:00
KD-MM2
d5ee823fbc refactor(docs): fix heading tag, update Vietnamese translation for image alt, formatting features table (#12971)
* feat(readme): add Vietnamese translation

* feat(readme): add Vietnamese translation

* refactor(readme): update Vietnamese translation section

* Update README_vi_VN.md

* refactor(docs): fix heading tag, update Vietnamese translation for image alt, formatting features table

---------

Co-authored-by: tdcaot <cao@sohobb.jp>
2024-09-27 02:40:00 +00:00
KD-MM2
26f33652e1 feat(docs): add Vietnamese translation (#12967)
* feat(readme): add Vietnamese translation

* feat(readme): add Vietnamese translation

* refactor(readme): update Vietnamese translation section

---------

Co-authored-by: tdcaot <cao@sohobb.jp>
2024-09-27 01:57:26 +00:00
Spencer Fasulo
c86fa81e47 docs(web): JSDoc comments for svelte actions (#12963)
* Web: JSDoc comments for Actions

* Remove comment
2024-09-27 01:41:22 +00:00
Lauritz Tieste
42ad3e6bb0 fix(mobile): navigation panel overlaps with right rotate (#12950)
fix: navigation panel overlaps with right rotate
2024-09-27 08:40:07 +07:00
Alex
a6e703ed6b chore(mobile): post release task (#12955) 2024-09-27 08:11:22 +07:00
Jason Rasmussen
b6f871786c fix(server): handle numeric hierarchical subject values (#12949) 2024-09-26 14:32:10 -04:00
Gus Price
62a490eca2 docs: add clarity to non root user section (#12956)
* clarity

* prettier
2024-09-26 17:34:01 +00:00
32 changed files with 365 additions and 159 deletions

View File

@@ -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>

View File

@@ -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`.

View File

@@ -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;

View File

@@ -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>

View File

@@ -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',
),
],
),
],
), ),
), ),
), ),
), ],
], );
); },
}, ),
), ),
); );
} }

View File

@@ -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
View 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
![Hoạt động](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "Hình ảnh phân tích Repobeats")
## 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>

View File

@@ -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?: {

View File

@@ -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'] });

View File

@@ -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) });
} }

View File

@@ -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;

View File

@@ -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();

View File

@@ -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;

View File

@@ -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();
}; };

View File

@@ -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[],

View File

@@ -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);

View File

@@ -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>[],

View File

@@ -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) {

View File

@@ -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}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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')} />

View File

@@ -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 />

View File

@@ -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;

View File

@@ -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"
> >

View File

@@ -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}

View File

@@ -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} />

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}

View File

@@ -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 />

View File

@@ -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')} />