Compare commits

...

29 Commits

Author SHA1 Message Date
Alex 536fda04f2 Up version for release 2022-10-04 15:29:47 -05:00
Alex 2094204877 Up version for release 2022-10-04 15:29:37 -05:00
Alex ab375cca1a Up Version for release 2022-10-04 15:21:58 -05:00
Alex 479f706f8a fix(mobile): Fix error parsing datetime prevent the timeline to be displayed (#784) 2022-10-04 15:19:29 -05:00
Deepesh Bhardwaj 4342285507 Updated jpeg thumbnail path (#780) 2022-10-04 09:46:06 -05:00
Jonas Janz 8bb656cb17 add docker volumes to services (#766)
* add docker volumes to services

this change adds the volume definitions for
/usr/src/app/upload
/usr/src/app/.reverse-geocoding-dump

to the `immich-server` docker-compose files
as /usr/src/app/upload should always be a volume for the containers
I also added it to the `Dockerfile`

Signed-off-by: PixelJonas <5434875+PixelJonas@users.noreply.github.com>

* remove geocoding-dump volume from docker-compose

Signed-off-by: PixelJonas <5434875+PixelJonas@users.noreply.github.com>
2022-10-01 16:01:27 -05:00
Alex 3f1f835df3 Update readme for beta release invitation links 2022-09-29 15:13:18 -05:00
Matthias Rupp 87ca031335 Fix bug with missing year and add date to drag handle (#761) 2022-09-29 10:19:55 -05:00
Alex Tran 96b9e37461 Up version for release 2022-09-28 16:28:14 -05:00
Alex Tran 0d3a2fe844 Added generated geocoding files to gitignore 2022-09-28 15:44:43 -05:00
Johannes Zellner 848781aef5 Provide a sensible dumpDirectory for the local-reverse-geocoder module (#759)
Fixes #758
2022-09-28 15:43:34 -05:00
Matthias Rupp 28bf497a0b feat(mobile): Improve timeline performance on mobile - experimental (#710) 2022-09-28 11:30:38 -05:00
Alex Tran 8ede738396 Up mobile version 2022-09-28 06:22:12 -05:00
Alex Tran 40c2b6a563 Update readme 2022-09-28 05:59:23 -05:00
Alex Tran 3581cf7305 Pump server version 2022-09-28 05:53:50 -05:00
Zack Pollard c33775b944 feat(server): missing exif extract nightly task (#754)
* fix: nightly reverse geocoding task checking for mapbox

* refactor: remove file size from image processor and queue data

* feat: add missing exif nightly job

* Remove filesize requirement in assetUploadedProcessorName queue insertion

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2022-09-28 05:41:50 -05:00
Alex b0cd2522e0 feat(server): support .NEF file (#746) 2022-09-23 19:09:45 -05:00
Alex c3979f6e31 fix(machine-learning) Remove unsused database config (#745) 2022-09-23 19:00:47 -05:00
Alex 103df4d9f3 fix(web) navigating forward button get in the way of video control bar (#744)
* fix(web) navigating forward button get in the way of video control bar

* Remove unsued style
2022-09-23 18:22:06 -05:00
Zack Pollard 040e02cfc5 fix(server): handle missing reverse geocoding admin zones (#742) 2022-09-23 10:14:42 -05:00
Zack Pollard f377b64065 feat(server) Remove mapbox and use local reverse geocoding (#738)
* feat: local reverse geocoding implementation, removes mapbox

* Disable non-null tslintrule

* Disable non-null tslintrule

* Remove tsignore

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2022-09-22 21:50:05 -05:00
Alex e5459b68ff fix(server,web,mobile): Incorrectly record and show timestamp and time zone of the asset (#706)
Implemented a mechanism to extract the correct time zone from the GPS coordinate if presented in the file's EXIF, and to convert the timestamp to the correct UTC time so that the time will show correctly based on the mobile/web local time zone.
2022-09-22 15:58:17 -05:00
Alex Tran fc194021a4 Pump server version 2022-09-22 11:38:50 -05:00
bo0tzz 39f8ca3bf1 Only run scheduled geocoding task once per day (#730) 2022-09-21 07:17:59 -05:00
Alex 7a807f7216 Update README.md 2022-09-19 16:04:22 -05:00
Alex bedfb51b1c Add demo URL 2022-09-19 16:00:51 -05:00
Alex b2afb95c19 Create codeql-analysis.yml 2022-09-19 14:03:49 -05:00
Alex 10239161fd fix(mobile): app crash when there is no object detection result on search page (#725)
* fix(mobile): app crash when there is no data for object detection

* Up version for release

* Up version for release
2022-09-19 11:03:51 -05:00
Alex 242f10952d fix(server): query only image (#724) 2022-09-19 10:48:44 -05:00
63 changed files with 3204 additions and 3100 deletions
+74
View File
@@ -0,0 +1,74 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: [ "main" ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ "main" ]
schedule:
- cron: '20 13 * * 1'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'javascript', 'python' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
steps:
- name: Checkout repository
uses: actions/checkout@v3
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v2
# ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
# If the Autobuild fails above, remove it and uncomment the following three lines.
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
# - run: |
# echo "Run, Build Application using script"
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
with:
category: "/language:${{matrix.language}}"
+23 -3
View File
@@ -23,12 +23,30 @@
<br/> <br/>
</p> </p>
## Demo
You can access the web demo at https://demo.immich.app
For the mobile app, you can use https://demo.immich.app/api for the `Server Endpoint URL`
```
The credential
email: demo@immich.app
password: demo
```
```
Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM
```
## Content ## Content
- [Features](#features) - [Features](#features)
- [Screenshots](#screenshots) - [Screenshots](#screenshots)
- [Installation](#installation) - [Installation](#installation)
- [Update](#update) - [Update](#update)
- [Mobile App](#-mobile-app) - [Mobile App](#mobile-app)
- [App Beta Invitation links](#App-Beta-release-channel)
- [Development](#development) - [Development](#development)
- [Support](#support) - [Support](#support)
- [Known Issues](#known-issues) - [Known Issues](#known-issues)
@@ -146,8 +164,6 @@ wget -O .env https://raw.githubusercontent.com/immich-app/immich/main/docker/.en
* Populate custom database information if necessary. * Populate custom database information if necessary.
* Populate `UPLOAD_LOCATION` as prefered location for storing backup assets. * Populate `UPLOAD_LOCATION` as prefered location for storing backup assets.
* Populate a secret value for `JWT_SECRET`, you can use this command: `openssl rand -base64 128` * Populate a secret value for `JWT_SECRET`, you can use this command: `openssl rand -base64 128`
* [Optional] Populate Mapbox value to use reverse geocoding.
* [Optional] Populate `TZ` as your timezone, default is `Etc/UTC`.
### Step 3 - Start the containers ### Step 3 - Start the containers
@@ -190,7 +206,11 @@ docker-compose pull && docker-compose up -d
> *The Play/App Store version might be lagging behind the latest release due to the review process.* > *The Play/App Store version might be lagging behind the latest release due to the review process.*
# App Beta release channel
You can opt-in to join app beta release channel by following the links below:
* Android: Invitation link from [web](https://play.google.com/store/apps/details?id=app.alextran.immich) or from [mobile](https://play.google.com/store/apps/details?id=app.alextran.immich)
* iOS: [TestFlight invitation link](https://testflight.apple.com/join/1vYsAa8P)
<br/> <br/>
# Development # Development
+11 -24
View File
@@ -10,9 +10,6 @@ DB_DATABASE_NAME=immich
# Optional Database settings: # Optional Database settings:
# DB_PORT=5432 # DB_PORT=5432
################################################################################### ###################################################################################
# Redis # Redis
################################################################################### ###################################################################################
@@ -25,41 +22,39 @@ REDIS_HOSTNAME=immich_redis
# REDIS_PASSWORD= # REDIS_PASSWORD=
# REDIS_SOCKET= # REDIS_SOCKET=
################################################################################### ###################################################################################
# Upload File Config # Upload File Config
################################################################################### ###################################################################################
UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_backup UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_backup
################################################################################### ###################################################################################
# Log message level - [simple|verbose] # Log message level - [simple|verbose]
################################################################################### ###################################################################################
LOG_LEVEL=simple LOG_LEVEL=simple
################################################################################### ###################################################################################
# JWT SECRET # JWT SECRET
################################################################################### ###################################################################################
JWT_SECRET=randomstringthatissolongandpowerfulthatnoonecanguess JWT_SECRET=randomstringthatissolongandpowerfulthatnoonecanguess
################################################################################### ###################################################################################
# MAPBOX # Reverse Geocoding
#################################################################################### ####################################################################################
# ENABLE_MAPBOX is either true of false -> if true, you have to provide MAPBOX_KEY # DISABLE_REVERSE_GEOCODING=false
ENABLE_MAPBOX=false
MAPBOX_KEY=
# Reverse geocoding is done locally which has a small impact on memory usage
# This memory usage can be altered by changing the REVERSE_GEOCODING_PRECISION variable
# This ranges from 0-3 with 3 being the most precise
# 3 - Cities > 500 population: ~200MB RAM
# 2 - Cities > 1000 population: ~150MB RAM
# 1 - Cities > 5000 population: ~80MB RAM
# 0 - Cities > 15000 population: ~40MB RAM
# REVERSE_GEOCODING_PRECISION=3
#################################################################################### ####################################################################################
# WEB - Optional # WEB - Optional
@@ -69,11 +64,3 @@ MAPBOX_KEY=
# For example PUBLIC_LOGIN_PAGE_MESSAGE="This is a demo instance of Immich.<br><br>Email: <i>demo@demo.de</i><br>Password: <i>demo</i>" # For example PUBLIC_LOGIN_PAGE_MESSAGE="This is a demo instance of Immich.<br><br>Email: <i>demo@demo.de</i><br>Password: <i>demo</i>"
PUBLIC_LOGIN_PAGE_MESSAGE= PUBLIC_LOGIN_PAGE_MESSAGE=
# For correctly display your local time zone on the web, you can set the time zone here.
# Should work fine by default value, however, in case of incorrect timezone in EXIF, this value
# should be set to the correct timezone.
# Command to get timezone:
# - Linux: curl -s http://ip-api.com/json/ | grep -oP '(?<=timezone":")(.*?)(?=")'
# TZ=Etc/UTC
-2
View File
@@ -47,8 +47,6 @@ services:
entrypoint: ["/bin/sh", "./entrypoint.sh"] entrypoint: ["/bin/sh", "./entrypoint.sh"]
env_file: env_file:
- .env - .env
environment:
- PUBLIC_TZ=${TZ}
restart: always restart: always
redis: redis:
+634 -1593
View File
File diff suppressed because it is too large Load Diff
+1 -4
View File
@@ -25,7 +25,6 @@
"@nestjs/core": "^8.0.0", "@nestjs/core": "^8.0.0",
"@nestjs/mapped-types": "^1.0.1", "@nestjs/mapped-types": "^1.0.1",
"@nestjs/platform-express": "^8.0.0", "@nestjs/platform-express": "^8.0.0",
"@nestjs/typeorm": "^8.0.3",
"@tensorflow-models/coco-ssd": "^2.2.2", "@tensorflow-models/coco-ssd": "^2.2.2",
"@tensorflow-models/mobilenet": "^2.1.0", "@tensorflow-models/mobilenet": "^2.1.0",
"@tensorflow/tfjs": "^3.19.0", "@tensorflow/tfjs": "^3.19.0",
@@ -34,11 +33,9 @@
"@tensorflow/tfjs-node": "^3.19.0", "@tensorflow/tfjs-node": "^3.19.0",
"@tensorflow/tfjs-node-gpu": "^3.19.0", "@tensorflow/tfjs-node-gpu": "^3.19.0",
"@trpc/server": "^9.20.3", "@trpc/server": "^9.20.3",
"pg": "^8.7.3",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"rxjs": "^7.2.0", "rxjs": "^7.2.0"
"typeorm": "^0.2.45"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^8.2.4", "@nestjs/cli": "^8.2.4",
+1 -7
View File
@@ -1,15 +1,9 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ImageClassifierModule } from './image-classifier/image-classifier.module'; import { ImageClassifierModule } from './image-classifier/image-classifier.module';
import { databaseConfig } from './config/database.config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ObjectDetectionModule } from './object-detection/object-detection.module'; import { ObjectDetectionModule } from './object-detection/object-detection.module';
@Module({ @Module({
imports: [ imports: [ImageClassifierModule, ObjectDetectionModule],
TypeOrmModule.forRoot(databaseConfig),
ImageClassifierModule,
ObjectDetectionModule,
],
controllers: [], controllers: [],
providers: [], providers: [],
}) })
@@ -1,11 +0,0 @@
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
export const databaseConfig: TypeOrmModuleOptions = {
type: 'postgres',
host: process.env.DB_HOSTNAME || 'immich_postgres',
port: parseInt(process.env.DB_PORT || '5432'),
username: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_DATABASE_NAME,
synchronize: false,
};
+9 -4
View File
@@ -16,12 +16,17 @@
default_platform(:android) default_platform(:android)
platform :android do platform :android do
desc "Build Android" desc "Build Android and Release Testing"
lane :build do lane :beta do
gradle( gradle(
task: 'bundle', task: 'bundle',
build_type: 'Release', build_type: 'Release',
properties: {
"android.injected.version.code" => 47,
"android.injected.version.name" => "1.30.2",
}
) )
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab', track: 'beta')
end end
desc "Build and Release Android" desc "Build and Release Android"
@@ -30,8 +35,8 @@ platform :android do
task: 'bundle', task: 'bundle',
build_type: 'Release', build_type: 'Release',
properties: { properties: {
"android.injected.version.code" => 43, "android.injected.version.code" => 48,
"android.injected.version.name" => "1.29.1", "android.injected.version.name" => "1.30.2",
} }
) )
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
+3 -3
View File
@@ -15,13 +15,13 @@ For _fastlane_ installation instructions, see [Installing _fastlane_](https://do
## Android ## Android
### android build ### android beta
```sh ```sh
[bundle exec] fastlane android build [bundle exec] fastlane android beta
``` ```
Build Android Build Android and Release Testing
### android release ### android release
@@ -0,0 +1 @@
* Fixed app crashes when there is no object detection result.
@@ -0,0 +1 @@
* Correctly display time based on timezone
@@ -0,0 +1 @@
* Added improvement for timeline view
@@ -0,0 +1 @@
* Improve scroll thumb date info
@@ -0,0 +1 @@
* Fixed parsing date error prevent timeline to be loaded.
+3 -3
View File
@@ -5,17 +5,17 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.00023"> <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000233">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="58.722434"> <testcase classname="fastlane.lanes" name="1: bundleRelease" time="61.699536">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="36.768014"> <testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="46.210553">
</testcase> </testcase>
+6 -1
View File
@@ -165,5 +165,10 @@
"version_announcement_overlay_text_1": "Hi friend, there is a new release of", "version_announcement_overlay_text_1": "Hi friend, there is a new release of",
"version_announcement_overlay_text_2": "please take your time to visit the ", "version_announcement_overlay_text_2": "please take your time to visit the ",
"version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.", "version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.",
"version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89" "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89",
"experimental_settings_title": "Experimental",
"experimental_settings_subtitle": "Use at your own risk!",
"experimental_settings_new_asset_list_title": "Enable experimental photo grid",
"experimental_settings_new_asset_list_subtitle": "Work in progress",
"settings_require_restart": "Please restart Immich to apply this setting"
} }
+3 -3
View File
@@ -360,7 +360,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 = 58; CURRENT_PROJECT_VERSION = 62;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@@ -495,7 +495,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 = 58; CURRENT_PROJECT_VERSION = 62;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@@ -522,7 +522,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 = 58; CURRENT_PROJECT_VERSION = 62;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
+2 -2
View File
@@ -17,11 +17,11 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.30.0</string> <string>1.30.1</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>58</string> <string>62</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true /> <true />
<key>MGLMapboxMetricsEnabledSettingShownInApp</key> <key>MGLMapboxMetricsEnabledSettingShownInApp</key>
+1 -1
View File
@@ -19,7 +19,7 @@ platform :ios do
desc "iOS Beta" desc "iOS Beta"
lane :beta do lane :beta do
increment_version_number( increment_version_number(
version_number: "1.29.1" version_number: "1.30.2"
) )
increment_build_number( increment_build_number(
build_number: latest_testflight_build_number + 1, build_number: latest_testflight_build_number + 1,
+6 -6
View File
@@ -5,32 +5,32 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000173"> <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000209">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.412813"> <testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.78333">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="3.69289"> <testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="3.947588">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.408563"> <testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.505399">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="4: build_app" time="65.350555"> <testcase classname="fastlane.lanes" name="4: build_app" time="80.954627">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="68.894733"> <testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="58.295965">
</testcase> </testcase>
@@ -81,7 +81,7 @@ class ExifBottomSheet extends ConsumerWidget {
if (assetDetail.exifInfo?.dateTimeOriginal != null) if (assetDetail.exifInfo?.dateTimeOriginal != null)
Text( Text(
DateFormat('date_format'.tr()).format( DateFormat('date_format'.tr()).format(
assetDetail.exifInfo!.dateTimeOriginal!, assetDetail.exifInfo!.dateTimeOriginal!.toLocal(),
), ),
style: TextStyle( style: TextStyle(
color: Colors.grey[400], color: Colors.grey[400],
@@ -508,7 +508,7 @@ class BackupControllerPage extends HookConsumerWidget {
DateTime.parse( DateTime.parse(
backupState.currentUploadAsset.createdAt backupState.currentUploadAsset.createdAt
.toString(), .toString(),
), ).toLocal(),
) )
], ],
), ),
@@ -90,7 +90,7 @@ class FailedBackupStatusPage extends HookConsumerWidget {
DateFormat.yMMMMd('en_US').format( DateFormat.yMMMMd('en_US').format(
DateTime.parse( DateTime.parse(
errorAsset.createdAt.toString(), errorAsset.createdAt.toString(),
), ).toLocal(),
), ),
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
@@ -0,0 +1,95 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:openapi/api.dart';
enum RenderAssetGridElementType {
assetRow,
dayTitle,
monthTitle;
}
class RenderAssetGridRow {
final List<AssetResponseDto> assets;
RenderAssetGridRow(this.assets);
}
class RenderAssetGridElement {
final RenderAssetGridElementType type;
final RenderAssetGridRow? assetRow;
final String? title;
final DateTime date;
final List<AssetResponseDto>? relatedAssetList;
RenderAssetGridElement(
this.type, {
this.assetRow,
this.title,
required this.date,
this.relatedAssetList,
});
}
final renderListProvider = StateProvider((ref) {
var assetGroups = ref.watch(assetGroupByDateTimeProvider);
var settings = ref.watch(appSettingsServiceProvider);
final assetsPerRow = settings.getSetting(AppSettingsEnum.tilesPerRow);
List<RenderAssetGridElement> elements = [];
DateTime? lastDate;
assetGroups.forEach((groupName, assets) {
try {
final date = DateTime.parse(groupName);
if (lastDate == null || lastDate!.month != date.month) {
elements.add(
RenderAssetGridElement(
RenderAssetGridElementType.monthTitle,
title: groupName,
date: date,
),
);
}
// Add group title
elements.add(
RenderAssetGridElement(
RenderAssetGridElementType.dayTitle,
title: groupName,
date: date,
relatedAssetList: assets,
),
);
// Add rows
int cursor = 0;
while (cursor < assets.length) {
int rowElements = min(assets.length - cursor, assetsPerRow);
final rowElement = RenderAssetGridElement(
RenderAssetGridElementType.assetRow,
date: date,
assetRow: RenderAssetGridRow(
assets.sublist(cursor, cursor + rowElements),
),
);
elements.add(rowElement);
cursor += rowElements;
}
lastDate = date;
} catch (e) {
debugPrint(e.toString());
}
});
return elements;
});
@@ -0,0 +1,107 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
import 'package:openapi/api.dart';
class DailyTitleText extends ConsumerWidget {
const DailyTitleText({
Key? key,
required this.isoDate,
required this.assetGroup,
}) : super(key: key);
final String isoDate;
final List<AssetResponseDto> assetGroup;
@override
Widget build(BuildContext context, WidgetRef ref) {
var currentYear = DateTime.now().year;
var groupYear = DateTime.parse(isoDate).year;
var formatDateTemplate = currentYear == groupYear
? "daily_title_text_date".tr()
: "daily_title_text_date_year".tr();
var dateText =
DateFormat(formatDateTemplate).format(DateTime.parse(isoDate));
var isMultiSelectEnable =
ref.watch(homePageStateProvider).isMultiSelectEnable;
var selectedDateGroup = ref.watch(homePageStateProvider).selectedDateGroup;
var selectedItems = ref.watch(homePageStateProvider).selectedItems;
void _handleTitleIconClick() {
if (isMultiSelectEnable &&
selectedDateGroup.contains(dateText) &&
selectedDateGroup.length == 1 &&
selectedItems.length <= assetGroup.length) {
// Multi select is active - click again on the icon while it is the only active group -> disable multi select
ref.watch(homePageStateProvider.notifier).disableMultiSelect();
} else if (isMultiSelectEnable &&
selectedDateGroup.contains(dateText) &&
selectedItems.length != assetGroup.length) {
// Multi select is active - click again on the icon while it is not the only active group -> remove that group from selected group/items
ref
.watch(homePageStateProvider.notifier)
.removeSelectedDateGroup(dateText);
ref
.watch(homePageStateProvider.notifier)
.removeMultipleSelectedItem(assetGroup);
} else if (isMultiSelectEnable &&
selectedDateGroup.contains(dateText) &&
selectedDateGroup.length > 1) {
ref
.watch(homePageStateProvider.notifier)
.removeSelectedDateGroup(dateText);
ref
.watch(homePageStateProvider.notifier)
.removeMultipleSelectedItem(assetGroup);
} else if (isMultiSelectEnable && !selectedDateGroup.contains(dateText)) {
ref
.watch(homePageStateProvider.notifier)
.addSelectedDateGroup(dateText);
ref
.watch(homePageStateProvider.notifier)
.addMultipleSelectedItems(assetGroup);
} else {
ref
.watch(homePageStateProvider.notifier)
.enableMultiSelect(assetGroup.toSet());
ref
.watch(homePageStateProvider.notifier)
.addSelectedDateGroup(dateText);
}
}
return Padding(
padding: const EdgeInsets.only(
top: 29.0,
bottom: 29.0,
left: 12.0,
right: 12.0,
),
child: Row(
children: [
Text(
dateText,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
GestureDetector(
onTap: _handleTitleIconClick,
child: isMultiSelectEnable && selectedDateGroup.contains(dateText)
? Icon(
Icons.check_circle_rounded,
color: Theme.of(context).primaryColor,
)
: const Icon(
Icons.check_circle_outline_rounded,
color: Colors.grey,
),
)
],
),
);
}
}
@@ -0,0 +1,535 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
/// Build the Scroll Thumb and label using the current configuration
typedef ScrollThumbBuilder = Widget Function(
Color backgroundColor,
Animation<double> thumbAnimation,
Animation<double> labelAnimation,
double height, {
Text? labelText,
BoxConstraints? labelConstraints,
});
/// Build a Text widget using the current scroll offset
typedef LabelTextBuilder = Text Function(int item);
/// A widget that will display a BoxScrollView with a ScrollThumb that can be dragged
/// for quick navigation of the BoxScrollView.
class DraggableScrollbar extends StatefulWidget {
/// The view that will be scrolled with the scroll thumb
final ScrollablePositionedList child;
final ItemPositionsListener itemPositionsListener;
/// A function that builds a thumb using the current configuration
final ScrollThumbBuilder scrollThumbBuilder;
/// The height of the scroll thumb
final double heightScrollThumb;
/// The background color of the label and thumb
final Color backgroundColor;
/// The amount of padding that should surround the thumb
final EdgeInsetsGeometry? padding;
/// Determines how quickly the scrollbar will animate in and out
final Duration scrollbarAnimationDuration;
/// How long should the thumb be visible before fading out
final Duration scrollbarTimeToFade;
/// Build a Text widget from the current offset in the BoxScrollView
final LabelTextBuilder? labelTextBuilder;
/// Determines box constraints for Container displaying label
final BoxConstraints? labelConstraints;
/// The ScrollController for the BoxScrollView
final ItemScrollController controller;
/// Determines scrollThumb displaying. If you draw own ScrollThumb and it is true you just don't need to use animation parameters in [scrollThumbBuilder]
final bool alwaysVisibleScrollThumb;
final Function(bool scrolling) scrollStateListener;
DraggableScrollbar.semicircle({
Key? key,
Key? scrollThumbKey,
this.alwaysVisibleScrollThumb = false,
required this.child,
required this.controller,
required this.itemPositionsListener,
required this.scrollStateListener,
this.heightScrollThumb = 48.0,
this.backgroundColor = Colors.white,
this.padding,
this.scrollbarAnimationDuration = const Duration(milliseconds: 300),
this.scrollbarTimeToFade = const Duration(milliseconds: 600),
this.labelTextBuilder,
this.labelConstraints,
}) : assert(child.scrollDirection == Axis.vertical),
scrollThumbBuilder = _thumbSemicircleBuilder(
heightScrollThumb * 0.6,
scrollThumbKey,
alwaysVisibleScrollThumb,
),
super(key: key);
@override
DraggableScrollbarState createState() => DraggableScrollbarState();
static buildScrollThumbAndLabel({
required Widget scrollThumb,
required Color backgroundColor,
required Animation<double>? thumbAnimation,
required Animation<double>? labelAnimation,
required Text? labelText,
required BoxConstraints? labelConstraints,
required bool alwaysVisibleScrollThumb,
}) {
var scrollThumbAndLabel = labelText == null
? scrollThumb
: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end,
children: [
ScrollLabel(
animation: labelAnimation,
backgroundColor: backgroundColor,
constraints: labelConstraints,
child: labelText,
),
scrollThumb,
],
);
if (alwaysVisibleScrollThumb) {
return scrollThumbAndLabel;
}
return SlideFadeTransition(
animation: thumbAnimation!,
child: scrollThumbAndLabel,
);
}
static ScrollThumbBuilder _thumbSemicircleBuilder(
double width,
Key? scrollThumbKey,
bool alwaysVisibleScrollThumb,
) {
return (
Color backgroundColor,
Animation<double> thumbAnimation,
Animation<double> labelAnimation,
double height, {
Text? labelText,
BoxConstraints? labelConstraints,
}) {
final scrollThumb = CustomPaint(
key: scrollThumbKey,
foregroundPainter: ArrowCustomPainter(Colors.white),
child: Material(
elevation: 4.0,
color: backgroundColor,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(height),
bottomLeft: Radius.circular(height),
topRight: const Radius.circular(4.0),
bottomRight: const Radius.circular(4.0),
),
child: Container(
constraints: BoxConstraints.tight(Size(width, height)),
),
),
);
return buildScrollThumbAndLabel(
scrollThumb: scrollThumb,
backgroundColor: backgroundColor,
thumbAnimation: thumbAnimation,
labelAnimation: labelAnimation,
labelText: labelText,
labelConstraints: labelConstraints,
alwaysVisibleScrollThumb: alwaysVisibleScrollThumb,
);
};
}
}
class ScrollLabel extends StatelessWidget {
final Animation<double>? animation;
final Color backgroundColor;
final Text child;
final BoxConstraints? constraints;
static const BoxConstraints _defaultConstraints =
BoxConstraints.tightFor(width: 72.0, height: 28.0);
const ScrollLabel({
Key? key,
required this.child,
required this.animation,
required this.backgroundColor,
this.constraints = _defaultConstraints,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: animation!,
child: Container(
margin: const EdgeInsets.only(right: 12.0),
child: Material(
elevation: 4.0,
color: backgroundColor,
borderRadius: const BorderRadius.all(Radius.circular(16.0)),
child: Container(
constraints: constraints ?? _defaultConstraints,
padding: const EdgeInsets.symmetric(horizontal: 10.0),
alignment: Alignment.center,
child: child,
),
),
),
);
}
}
class DraggableScrollbarState extends State<DraggableScrollbar>
with TickerProviderStateMixin {
late double _barOffset;
late bool _isDragInProcess;
late int _currentItem;
late AnimationController _thumbAnimationController;
late Animation<double> _thumbAnimation;
late AnimationController _labelAnimationController;
late Animation<double> _labelAnimation;
Timer? _fadeoutTimer;
@override
void initState() {
super.initState();
_barOffset = 0.0;
_isDragInProcess = false;
_currentItem = 0;
_thumbAnimationController = AnimationController(
vsync: this,
duration: widget.scrollbarAnimationDuration,
);
_thumbAnimation = CurvedAnimation(
parent: _thumbAnimationController,
curve: Curves.fastOutSlowIn,
);
_labelAnimationController = AnimationController(
vsync: this,
duration: widget.scrollbarAnimationDuration,
);
_labelAnimation = CurvedAnimation(
parent: _labelAnimationController,
curve: Curves.fastOutSlowIn,
);
}
@override
void dispose() {
_thumbAnimationController.dispose();
_labelAnimationController.dispose();
_fadeoutTimer?.cancel();
super.dispose();
}
double get barMaxScrollExtent =>
(context.size?.height ?? 0) - widget.heightScrollThumb;
double get barMinScrollExtent => 0;
int get maxItemCount => widget.child.itemCount;
@override
Widget build(BuildContext context) {
Text? labelText;
if (widget.labelTextBuilder != null && _isDragInProcess) {
labelText = widget.labelTextBuilder!(_currentItem);
}
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
//print("LayoutBuilder constraints=$constraints");
return NotificationListener<ScrollNotification>(
onNotification: (ScrollNotification notification) {
changePosition(notification);
return false;
},
child: Stack(
children: <Widget>[
RepaintBoundary(
child: widget.child,
),
RepaintBoundary(
child: GestureDetector(
onVerticalDragStart: _onVerticalDragStart,
onVerticalDragUpdate: _onVerticalDragUpdate,
onVerticalDragEnd: _onVerticalDragEnd,
child: Container(
alignment: Alignment.topRight,
margin: EdgeInsets.only(top: _barOffset),
padding: widget.padding,
child: widget.scrollThumbBuilder(
widget.backgroundColor,
_thumbAnimation,
_labelAnimation,
widget.heightScrollThumb,
labelText: labelText,
labelConstraints: widget.labelConstraints,
),
),
),
),
],
),
);
},
);
}
// scroll bar has received notification that it's view was scrolled
// so it should also changes his position
// but only if it isn't dragged
changePosition(ScrollNotification notification) {
if (_isDragInProcess) {
return;
}
setState(() {
int firstItemIndex =
widget.itemPositionsListener.itemPositions.value.first.index;
if (notification is ScrollUpdateNotification) {
_barOffset = (firstItemIndex / maxItemCount) * barMaxScrollExtent;
if (_barOffset < barMinScrollExtent) {
_barOffset = barMinScrollExtent;
}
if (_barOffset > barMaxScrollExtent) {
_barOffset = barMaxScrollExtent;
}
}
if (notification is ScrollUpdateNotification ||
notification is OverscrollNotification) {
if (_thumbAnimationController.status != AnimationStatus.forward) {
_thumbAnimationController.forward();
}
if (itemPos < maxItemCount) {
_currentItem = itemPos;
}
_fadeoutTimer?.cancel();
_fadeoutTimer = Timer(widget.scrollbarTimeToFade, () {
_thumbAnimationController.reverse();
_labelAnimationController.reverse();
_fadeoutTimer = null;
});
}
});
}
void _onVerticalDragStart(DragStartDetails details) {
setState(() {
_isDragInProcess = true;
_labelAnimationController.forward();
_fadeoutTimer?.cancel();
});
widget.scrollStateListener(true);
}
int get itemPos {
int numberOfItems = widget.child.itemCount;
return ((_barOffset / barMaxScrollExtent) * numberOfItems).toInt();
}
void _jumpToBarPos() {
if (itemPos > maxItemCount - 1) {
return;
}
_currentItem = itemPos;
widget.controller.jumpTo(
index: itemPos,
);
}
Timer? dragHaltTimer;
int lastTimerPos = 0;
void _onVerticalDragUpdate(DragUpdateDetails details) {
setState(() {
if (_thumbAnimationController.status != AnimationStatus.forward) {
_thumbAnimationController.forward();
}
if (_isDragInProcess) {
_barOffset += details.delta.dy;
if (_barOffset < barMinScrollExtent) {
_barOffset = barMinScrollExtent;
}
if (_barOffset > barMaxScrollExtent) {
_barOffset = barMaxScrollExtent;
}
if (itemPos != lastTimerPos) {
lastTimerPos = itemPos;
dragHaltTimer?.cancel();
widget.scrollStateListener(true);
dragHaltTimer = Timer(
const Duration(milliseconds: 200),
() {
widget.scrollStateListener(false);
},
);
}
_jumpToBarPos();
}
});
}
void _onVerticalDragEnd(DragEndDetails details) {
_fadeoutTimer = Timer(widget.scrollbarTimeToFade, () {
_thumbAnimationController.reverse();
_labelAnimationController.reverse();
_fadeoutTimer = null;
});
setState(() {
_jumpToBarPos();
_isDragInProcess = false;
});
widget.scrollStateListener(false);
}
}
/// Draws 2 triangles like arrow up and arrow down
class ArrowCustomPainter extends CustomPainter {
Color color;
ArrowCustomPainter(this.color);
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..color = color;
const width = 12.0;
const height = 8.0;
final baseX = size.width / 2;
final baseY = size.height / 2;
canvas.drawPath(
_trianglePath(Offset(baseX, baseY - 2.0), width, height, true),
paint,
);
canvas.drawPath(
_trianglePath(Offset(baseX, baseY + 2.0), width, height, false),
paint,
);
}
static Path _trianglePath(Offset o, double width, double height, bool isUp) {
return Path()
..moveTo(o.dx, o.dy)
..lineTo(o.dx + width, o.dy)
..lineTo(o.dx + (width / 2), isUp ? o.dy - height : o.dy + height)
..close();
}
}
///This cut 2 lines in arrow shape
class ArrowClipper extends CustomClipper<Path> {
@override
Path getClip(Size size) {
Path path = Path();
path.lineTo(0.0, size.height);
path.lineTo(size.width, size.height);
path.lineTo(size.width, 0.0);
path.lineTo(0.0, 0.0);
path.close();
double arrowWidth = 8.0;
double startPointX = (size.width - arrowWidth) / 2;
double startPointY = size.height / 2 - arrowWidth / 2;
path.moveTo(startPointX, startPointY);
path.lineTo(startPointX + arrowWidth / 2, startPointY - arrowWidth / 2);
path.lineTo(startPointX + arrowWidth, startPointY);
path.lineTo(startPointX + arrowWidth, startPointY + 1.0);
path.lineTo(
startPointX + arrowWidth / 2,
startPointY - arrowWidth / 2 + 1.0,
);
path.lineTo(startPointX, startPointY + 1.0);
path.close();
startPointY = size.height / 2 + arrowWidth / 2;
path.moveTo(startPointX + arrowWidth, startPointY);
path.lineTo(startPointX + arrowWidth / 2, startPointY + arrowWidth / 2);
path.lineTo(startPointX, startPointY);
path.lineTo(startPointX, startPointY - 1.0);
path.lineTo(
startPointX + arrowWidth / 2,
startPointY + arrowWidth / 2 - 1.0,
);
path.lineTo(startPointX + arrowWidth, startPointY - 1.0);
path.close();
return path;
}
@override
bool shouldReclip(CustomClipper<Path> oldClipper) => false;
}
class SlideFadeTransition extends StatelessWidget {
final Animation<double> animation;
final Widget child;
const SlideFadeTransition({
Key? key,
required this.animation,
required this.child,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: animation,
builder: (context, child) =>
animation.value == 0.0 ? const SizedBox() : child!,
child: SlideTransition(
position: Tween(
begin: const Offset(0.3, 0.0),
end: const Offset(0.0, 0.0),
).animate(animation),
child: FadeTransition(
opacity: animation,
child: child,
),
),
);
}
}
@@ -0,0 +1,167 @@
import 'dart:math';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/src/widgets/framework.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/providers/home_page_render_list_provider.dart';
import 'package:immich_mobile/modules/home/ui/asset_list_v2/daily_title_text.dart';
import 'package:immich_mobile/modules/home/ui/asset_list_v2/draggable_scrollbar_custom.dart';
import 'package:openapi/api.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import '../thumbnail_image.dart';
class ImmichAssetGrid extends HookConsumerWidget {
final ItemScrollController _itemScrollController = ItemScrollController();
final ItemPositionsListener _itemPositionsListener =
ItemPositionsListener.create();
final List<RenderAssetGridElement> renderList;
final int assetsPerRow;
final double margin;
final bool showStorageIndicator;
ImmichAssetGrid({
super.key,
required this.renderList,
required this.assetsPerRow,
required this.showStorageIndicator,
this.margin = 5.0,
});
List<AssetResponseDto> get _assets {
return renderList
.map((e) {
if (e.type == RenderAssetGridElementType.assetRow) {
return e.assetRow!.assets;
} else {
return List<AssetResponseDto>.empty();
}
})
.flattened
.toList();
}
double _getItemSize(BuildContext context) {
return MediaQuery.of(context).size.width / assetsPerRow -
margin * (assetsPerRow - 1) / assetsPerRow;
}
Widget _buildThumbnailOrPlaceholder(
AssetResponseDto asset, bool placeholder) {
if (placeholder) {
return const DecoratedBox(
decoration: BoxDecoration(color: Colors.grey),
);
}
return ThumbnailImage(
asset: asset,
assetList: _assets,
showStorageIndicator: showStorageIndicator,
useGrayBoxPlaceholder: true,
);
}
Widget _buildAssetRow(
BuildContext context, RenderAssetGridRow row, bool scrolling) {
double size = _getItemSize(context);
return Row(
key: Key("asset-row-${row.assets.first.id}"),
children: row.assets.map((AssetResponseDto asset) {
bool last = asset == row.assets.last;
return Container(
key: Key("asset-${asset.id}"),
width: size,
height: size,
margin: EdgeInsets.only(top: margin, right: last ? 0.0 : margin),
child: _buildThumbnailOrPlaceholder(asset, scrolling),
);
}).toList(),
);
}
Widget _buildTitle(
BuildContext context, String title, List<AssetResponseDto> assets) {
return DailyTitleText(
isoDate: title,
assetGroup: assets,
);
}
Widget _buildMonthTitle(BuildContext context, String title) {
var monthTitleText = DateFormat("monthly_title_text_date_format".tr())
.format(DateTime.parse(title));
return Padding(
key: Key("month-$title"),
padding: const EdgeInsets.only(left: 12.0, top: 32),
child: Text(
monthTitleText,
style: TextStyle(
fontSize: 26,
fontWeight: FontWeight.bold,
color: Theme.of(context).textTheme.headline1?.color,
),
),
);
}
Widget _itemBuilder(BuildContext c, int position, bool scrolling) {
final item = renderList[position];
if (item.type == RenderAssetGridElementType.dayTitle) {
return _buildTitle(c, item.title!, item.relatedAssetList!);
} else if (item.type == RenderAssetGridElementType.monthTitle) {
return _buildMonthTitle(c, item.title!);
} else if (item.type == RenderAssetGridElementType.assetRow) {
return _buildAssetRow(c, item.assetRow!, scrolling);
}
return const Text("Invalid widget type!");
}
Text _labelBuilder(int pos) {
final date = renderList[pos].date;
return Text(DateFormat.yMMMd().format(date),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final scrolling = useState(false);
void dragScrolling(bool active) {
scrolling.value = active;
}
Widget itemBuilder(BuildContext c, int position) {
return _itemBuilder(c, position, scrolling.value);
}
return DraggableScrollbar.semicircle(
scrollStateListener: dragScrolling,
itemPositionsListener: _itemPositionsListener,
controller: _itemScrollController,
backgroundColor: Theme.of(context).hintColor,
labelTextBuilder: _labelBuilder,
labelConstraints: const BoxConstraints(maxHeight: 28),
scrollbarAnimationDuration: const Duration(seconds: 1),
scrollbarTimeToFade: const Duration(seconds: 4),
child: ScrollablePositionedList.builder(
itemBuilder: itemBuilder,
itemPositionsListener: _itemPositionsListener,
itemScrollController: _itemScrollController,
itemCount: renderList.length,
));
}
}
@@ -21,8 +21,8 @@ class DailyTitleText extends ConsumerWidget {
var formatDateTemplate = currentYear == groupYear var formatDateTemplate = currentYear == groupYear
? "daily_title_text_date".tr() ? "daily_title_text_date".tr()
: "daily_title_text_date_year".tr(); : "daily_title_text_date_year".tr();
var dateText = var dateText = DateFormat(formatDateTemplate)
DateFormat(formatDateTemplate).format(DateTime.parse(isoDate)); .format(DateTime.parse(isoDate).toLocal());
var isMultiSelectEnable = var isMultiSelectEnable =
ref.watch(homePageStateProvider).isMultiSelectEnable; ref.watch(homePageStateProvider).isMultiSelectEnable;
var selectedDateGroup = ref.watch(homePageStateProvider).selectedDateGroup; var selectedDateGroup = ref.watch(homePageStateProvider).selectedDateGroup;
+4 -28
View File
@@ -33,34 +33,10 @@ class ImageGrid extends ConsumerWidget {
var assetType = assetGroup[index].type; var assetType = assetGroup[index].type;
return GestureDetector( return GestureDetector(
onTap: () {}, onTap: () {},
child: Stack( child: ThumbnailImage(
children: [ asset: assetGroup[index],
ThumbnailImage( assetList: sortedAssetGroup,
asset: assetGroup[index], showStorageIndicator: showStorageIndicator,
assetList: sortedAssetGroup,
showStorageIndicator: showStorageIndicator,
),
if (assetType != AssetTypeEnum.IMAGE)
Positioned(
top: 5,
right: 5,
child: Row(
children: [
Text(
assetGroup[index].duration.toString().substring(0, 7),
style: const TextStyle(
color: Colors.white,
fontSize: 10,
),
),
const Icon(
Icons.play_circle_outline_rounded,
color: Colors.white,
),
],
),
),
],
), ),
); );
}, },
@@ -12,7 +12,7 @@ class MonthlyTitleText extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var monthTitleText = DateFormat("monthly_title_text_date_format".tr()) var monthTitleText = DateFormat("monthly_title_text_date_format".tr())
.format(DateTime.parse(isoDate)); .format(DateTime.parse(isoDate).toLocal());
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: Padding( child: Padding(
@@ -15,12 +15,14 @@ class ThumbnailImage extends HookConsumerWidget {
final AssetResponseDto asset; final AssetResponseDto asset;
final List<AssetResponseDto> assetList; final List<AssetResponseDto> assetList;
final bool showStorageIndicator; final bool showStorageIndicator;
final bool useGrayBoxPlaceholder;
const ThumbnailImage({ const ThumbnailImage({
Key? key, Key? key,
required this.asset, required this.asset,
required this.assetList, required this.assetList,
this.showStorageIndicator = true, this.showStorageIndicator = true,
this.useGrayBoxPlaceholder = false,
}) : super(key: key); }) : super(key: key);
@override @override
@@ -102,13 +104,19 @@ class ThumbnailImage extends HookConsumerWidget {
"Authorization": "Bearer ${box.get(accessTokenKey)}" "Authorization": "Bearer ${box.get(accessTokenKey)}"
}, },
fadeInDuration: const Duration(milliseconds: 250), fadeInDuration: const Duration(milliseconds: 250),
progressIndicatorBuilder: (context, url, downloadProgress) => progressIndicatorBuilder: (context, url, downloadProgress) {
Transform.scale( if (useGrayBoxPlaceholder) {
scale: 0.2, return const DecoratedBox(
child: CircularProgressIndicator( decoration: BoxDecoration(color: Colors.grey),
value: downloadProgress.progress, );
), }
), return Transform.scale(
scale: 0.2,
child: CircularProgressIndicator(
value: downloadProgress.progress,
),
);
},
errorWidget: (context, url, error) { errorWidget: (context, url, error) {
debugPrint("Error getting thumbnail $url = $error"); debugPrint("Error getting thumbnail $url = $error");
CachedNetworkImage.evictFromCache(thumbnailRequestUrl); CachedNetworkImage.evictFromCache(thumbnailRequestUrl);
@@ -139,7 +147,27 @@ class ThumbnailImage extends HookConsumerWidget {
color: Colors.white, color: Colors.white,
size: 18, size: 18,
), ),
) ),
if (asset.type != AssetTypeEnum.IMAGE)
Positioned(
top: 5,
right: 5,
child: Row(
children: [
Text(
asset.duration.toString().substring(0, 7),
style: const TextStyle(
color: Colors.white,
fontSize: 10,
),
),
const Icon(
Icons.play_circle_outline_rounded,
color: Colors.white,
),
],
),
),
], ],
), ),
), ),
+30 -11
View File
@@ -1,12 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/providers/home_page_render_list_provider.dart';
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart'; import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart'; import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart';
import 'package:immich_mobile/modules/home/ui/daily_title_text.dart'; import 'package:immich_mobile/modules/home/ui/daily_title_text.dart';
import 'package:immich_mobile/modules/home/ui/disable_multi_select_button.dart'; import 'package:immich_mobile/modules/home/ui/disable_multi_select_button.dart';
import 'package:immich_mobile/modules/home/ui/draggable_scrollbar.dart'; import 'package:immich_mobile/modules/home/ui/draggable_scrollbar.dart';
import 'package:immich_mobile/modules/home/ui/image_grid.dart'; import 'package:immich_mobile/modules/home/ui/image_grid.dart';
import 'package:immich_mobile/modules/home/ui/asset_list_v2/immich_asset_grid.dart';
import 'package:immich_mobile/modules/home/ui/immich_sliver_appbar.dart'; import 'package:immich_mobile/modules/home/ui/immich_sliver_appbar.dart';
import 'package:immich_mobile/modules/home/ui/monthly_title_text.dart'; import 'package:immich_mobile/modules/home/ui/monthly_title_text.dart';
import 'package:immich_mobile/modules/home/ui/profile_drawer/profile_drawer.dart'; import 'package:immich_mobile/modules/home/ui/profile_drawer/profile_drawer.dart';
@@ -25,6 +27,8 @@ class HomePage extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final appSettingService = ref.watch(appSettingsServiceProvider); final appSettingService = ref.watch(appSettingsServiceProvider);
var renderList = ref.watch(renderListProvider);
ScrollController scrollController = useScrollController(); ScrollController scrollController = useScrollController();
var assetGroupByDateTime = ref.watch(assetGroupByDateTimeProvider); var assetGroupByDateTime = ref.watch(assetGroupByDateTimeProvider);
List<Widget> imageGridGroup = []; List<Widget> imageGridGroup = [];
@@ -120,6 +124,31 @@ class HomePage extends HookConsumerWidget {
); );
} }
_buildAssetGrid() {
if (appSettingService
.getSetting(AppSettingsEnum.useExperimentalAssetGrid)) {
return ImmichAssetGrid(
renderList: renderList,
assetsPerRow:
appSettingService.getSetting(AppSettingsEnum.tilesPerRow),
showStorageIndicator: appSettingService
.getSetting(AppSettingsEnum.storageIndicator),
);
} else {
return DraggableScrollbar.semicircle(
backgroundColor: Theme.of(context).hintColor,
controller: scrollController,
heightScrollThumb: 48.0,
child: CustomScrollView(
controller: scrollController,
slivers: [
...imageGridGroup,
],
),
);
}
}
return SafeArea( return SafeArea(
bottom: !isMultiSelectEnable, bottom: !isMultiSelectEnable,
top: !isMultiSelectEnable, top: !isMultiSelectEnable,
@@ -132,17 +161,7 @@ class HomePage extends HookConsumerWidget {
), ),
Padding( Padding(
padding: const EdgeInsets.only(top: 60.0, bottom: 0.0), padding: const EdgeInsets.only(top: 60.0, bottom: 0.0),
child: DraggableScrollbar.semicircle( child: _buildAssetGrid(),
backgroundColor: Theme.of(context).hintColor,
controller: scrollController,
heightScrollThumb: 48.0,
child: CustomScrollView(
controller: scrollController,
slivers: [
...imageGridGroup,
],
),
),
), ),
if (isMultiSelectEnable) ...[ if (isMultiSelectEnable) ...[
_buildSelectedItemCountIndicator(), _buildSelectedItemCountIndicator(),
@@ -62,7 +62,7 @@ final searchResultGroupByDateTimeProvider = StateProvider((ref) {
(a, b) => b.compareTo(a), (a, b) => b.compareTo(a),
); );
return assets.groupListsBy( return assets.groupListsBy(
(element) => (element) => DateFormat('y-MM-dd')
DateFormat('y-MM-dd').format(DateTime.parse(element.createdAt)), .format(DateTime.parse(element.createdAt).toLocal()),
); );
}); });
@@ -99,9 +99,9 @@ class SearchPage extends HookConsumerWidget {
_buildThings() { _buildThings() {
return curatedObjects.when( return curatedObjects.when(
loading: () => const SizedBox( loading: () => SizedBox(
height: 200, height: imageSize,
child: Center(child: ImmichLoadingIndicator()), child: const Center(child: ImmichLoadingIndicator()),
), ),
error: (err, stack) => Text('Error: $err'), error: (err, stack) => Text('Error: $err'),
data: (objects) { data: (objects) {
@@ -133,8 +133,7 @@ class SearchPage extends HookConsumerWidget {
), ),
) )
: SizedBox( : SizedBox(
// height: imageSize, height: imageSize,
width: imageSize,
child: ListView.builder( child: ListView.builder(
padding: const EdgeInsets.only(left: 16), padding: const EdgeInsets.only(left: 16),
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
@@ -10,7 +10,8 @@ enum AppSettingsEnum<T> {
storageIndicator<bool>("storageIndicator", true), storageIndicator<bool>("storageIndicator", true),
thumbnailCacheSize<int>("thumbnailCacheSize", 10000), thumbnailCacheSize<int>("thumbnailCacheSize", 10000),
imageCacheSize<int>("imageCacheSize", 350), imageCacheSize<int>("imageCacheSize", 350),
albumThumbnailCacheSize<int>("albumThumbnailCacheSize", 200); albumThumbnailCacheSize<int>("albumThumbnailCacheSize", 200),
useExperimentalAssetGrid<bool>("useExperimentalAssetGrid", false);
const AppSettingsEnum(this.hiveKey, this.defaultValue); const AppSettingsEnum(this.hiveKey, this.defaultValue);
@@ -0,0 +1,80 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
class ExperimentalSettings extends HookConsumerWidget {
const ExperimentalSettings({
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final appSettingService = ref.watch(appSettingsServiceProvider);
final useExperimentalAssetGrid = useState(false);
useEffect(
() {
useExperimentalAssetGrid.value = appSettingService
.getSetting(AppSettingsEnum.useExperimentalAssetGrid);
return null;
},
[],
);
void changeUseExperimentalAssetGrid(bool status) {
useExperimentalAssetGrid.value = status;
appSettingService.setSetting(
AppSettingsEnum.useExperimentalAssetGrid,
status,
);
ImmichToast.show(
context: context,
msg: "settings_require_restart".tr(),
gravity: ToastGravity.BOTTOM,
);
}
return ExpansionTile(
textColor: Theme.of(context).primaryColor,
title: const Text(
'experimental_settings_title',
style: TextStyle(
fontWeight: FontWeight.bold,
),
).tr(),
subtitle: const Text(
'experimental_settings_subtitle',
style: TextStyle(
fontSize: 13,
),
).tr(),
children: [
SwitchListTile.adaptive(
activeColor: Theme.of(context).primaryColor,
title: const Text(
"experimental_settings_new_asset_list_title",
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
),
).tr(),
subtitle: const Text(
"experimental_settings_new_asset_list_subtitle",
style: TextStyle(
fontSize: 12,
),
).tr(),
value: useExperimentalAssetGrid.value,
onChanged: changeUseExperimentalAssetGrid,
),
],
);
}
}
@@ -4,6 +4,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/settings/ui/asset_list_settings/asset_list_settings.dart'; import 'package:immich_mobile/modules/settings/ui/asset_list_settings/asset_list_settings.dart';
import 'package:immich_mobile/modules/settings/ui/experimental_settings/experimental_settings.dart';
import 'package:immich_mobile/modules/settings/ui/image_viewer_quality_setting/image_viewer_quality_setting.dart'; import 'package:immich_mobile/modules/settings/ui/image_viewer_quality_setting/image_viewer_quality_setting.dart';
import 'package:immich_mobile/modules/settings/ui/notification_setting/notification_setting.dart'; import 'package:immich_mobile/modules/settings/ui/notification_setting/notification_setting.dart';
import 'package:immich_mobile/modules/settings/ui/theme_setting/theme_setting.dart'; import 'package:immich_mobile/modules/settings/ui/theme_setting/theme_setting.dart';
@@ -42,6 +43,7 @@ class SettingsPage extends HookConsumerWidget {
const ThemeSetting(), const ThemeSetting(),
const AssetListSettings(), const AssetListSettings(),
if (Platform.isAndroid) const NotificationSetting(), if (Platform.isAndroid) const NotificationSetting(),
const ExperimentalSettings(),
], ],
).toList(), ).toList(),
], ],
@@ -81,8 +81,8 @@ final assetGroupByDateTimeProvider = StateProvider((ref) {
(a, b) => b.compareTo(a), (a, b) => b.compareTo(a),
); );
return assets.groupListsBy( return assets.groupListsBy(
(element) => (element) => DateFormat('y-MM-dd')
DateFormat('y-MM-dd').format(DateTime.parse(element.createdAt)), .format(DateTime.parse(element.createdAt).toLocal()),
); );
}); });
@@ -95,7 +95,7 @@ final assetGroupByMonthYearProvider = StateProvider((ref) {
); );
return assets.groupListsBy( return assets.groupListsBy(
(element) => (element) => DateFormat('MMMM, y')
DateFormat('MMMM, y').format(DateTime.parse(element.createdAt)), .format(DateTime.parse(element.createdAt).toLocal()),
); );
}); });
+2 -1
View File
@@ -10,6 +10,7 @@ class ImmichToast {
ToastType toastType = ToastType.info, ToastType toastType = ToastType.info,
ToastGravity gravity = ToastGravity.TOP, ToastGravity gravity = ToastGravity.TOP,
}) { }) {
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
final fToast = FToast(); final fToast = FToast();
fToast.init(context); fToast.init(context);
@@ -49,7 +50,7 @@ class ImmichToast {
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0), padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0),
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(5.0), borderRadius: BorderRadius.circular(5.0),
color: Colors.grey[50], color: isDarkTheme ? Colors.grey[900] : Colors.grey[50],
border: Border.all( border: Border.all(
color: Colors.black12, color: Colors.black12,
width: 1, width: 1,
+7
View File
@@ -868,6 +868,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.27.3" version: "0.27.3"
scrollable_positioned_list:
dependency: "direct main"
description:
name: scrollable_positioned_list
url: "https://pub.dartlang.org"
source: hosted
version: "0.3.4"
share_plus: share_plus:
dependency: "direct main" dependency: "direct main"
description: description:
+2 -1
View File
@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone description: Immich - selfhosted backup media file on mobile phone
publish_to: "none" publish_to: "none"
version: 1.29.1+43 version: 1.30.2+48
environment: environment:
sdk: ">=2.17.0 <3.0.0" sdk: ">=2.17.0 <3.0.0"
@@ -43,6 +43,7 @@ dependencies:
easy_localization: ^3.0.1 easy_localization: ^3.0.1
share_plus: ^4.0.10 share_plus: ^4.0.10
flutter_displaymode: ^0.4.0 flutter_displaymode: ^0.4.0
scrollable_positioned_list: ^0.3.4
path: ^1.8.1 path: ^1.8.1
path_provider: ^2.0.11 path_provider: ^2.0.11
+2 -1
View File
@@ -38,4 +38,5 @@ lerna-debug.log*
dist/ dist/
upload/ upload/
tmp/ tmp/
core core
.reverse-geocoding-dump/
+2
View File
@@ -29,4 +29,6 @@ COPY --from=builder /usr/src/app/dist ./dist
RUN npm prune --production RUN npm prune --production
VOLUME /usr/src/app/upload
EXPOSE 3001 EXPOSE 3001
@@ -171,7 +171,6 @@ export class AssetRepository implements IAssetRepository {
.createQueryBuilder('asset') .createQueryBuilder('asset')
.where('asset.userId = :userId', { userId: userId }) .where('asset.userId = :userId', { userId: userId })
.andWhere('asset.resizePath is not NULL') .andWhere('asset.resizePath is not NULL')
.andWhere('asset.type = :type', { type: AssetType.IMAGE })
.leftJoinAndSelect('asset.exifInfo', 'exifInfo') .leftJoinAndSelect('asset.exifInfo', 'exifInfo')
.orderBy('asset.createdAt', 'DESC'); .orderBy('asset.createdAt', 'DESC');
@@ -226,7 +225,6 @@ export class AssetRepository implements IAssetRepository {
where: { where: {
userId: userId, userId: userId,
deviceId: deviceId, deviceId: deviceId,
type: AssetType.IMAGE,
}, },
select: ['deviceAssetId'], select: ['deviceAssetId'],
}); });
@@ -97,7 +97,7 @@ export class AssetController {
await this.assetUploadedQueue.add( await this.assetUploadedQueue.add(
assetUploadedProcessorName, assetUploadedProcessorName,
{ asset: savedAsset, fileName: file.originalname, fileSize: file.size }, { asset: savedAsset, fileName: file.originalname },
{ jobId: savedAsset.id }, { jobId: savedAsset.id },
); );
@@ -11,7 +11,6 @@ import {
UseInterceptors, UseInterceptors,
UploadedFile, UploadedFile,
Response, Response,
Request,
ParseBoolPipe, ParseBoolPipe,
} from '@nestjs/common'; } from '@nestjs/common';
import { UserService } from './user.service'; import { UserService } from './user.service';
@@ -22,7 +21,7 @@ import { AdminRolesGuard } from '../../middlewares/admin-role-guard.middleware';
import { UpdateUserDto } from './dto/update-user.dto'; import { UpdateUserDto } from './dto/update-user.dto';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import { profileImageUploadOption } from '../../config/profile-image-upload.config'; import { profileImageUploadOption } from '../../config/profile-image-upload.config';
import { Response as Res, Request as Req } from 'express'; import { Response as Res } from 'express';
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { UserResponseDto } from './response-dto/user-response.dto'; import { UserResponseDto } from './response-dto/user-response.dto';
import { UserCountResponseDto } from './response-dto/user-count-response.dto'; import { UserCountResponseDto } from './response-dto/user-count-response.dto';
@@ -93,9 +92,7 @@ export class UserController {
async createProfileImage( async createProfileImage(
@GetAuthUser() authUser: AuthUserDto, @GetAuthUser() authUser: AuthUserDto,
@UploadedFile() fileInfo: Express.Multer.File, @UploadedFile() fileInfo: Express.Multer.File,
@Request() req: Req,
): Promise<CreateProfileImageResponseDto> { ): Promise<CreateProfileImageResponseDto> {
console.log(req.body, req.file);
return await this.userService.createProfileImage(authUser, fileInfo); return await this.userService.createProfileImage(authUser, fileInfo);
} }
+1 -2
View File
@@ -15,7 +15,6 @@ import { AppController } from './app.controller';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
import { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.module'; import { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.module';
import { DatabaseModule } from '@app/database'; import { DatabaseModule } from '@app/database';
import { AppLoggerMiddleware } from './middlewares/app-logger.middleware';
@Module({ @Module({
imports: [ imports: [
@@ -65,7 +64,7 @@ export class AppModule implements NestModule {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
configure(consumer: MiddlewareConsumer): void { configure(consumer: MiddlewareConsumer): void {
if (process.env.NODE_ENV == 'development') { if (process.env.NODE_ENV == 'development') {
consumer.apply(AppLoggerMiddleware).forRoutes('*'); // consumer.apply(AppLoggerMiddleware).forRoutes('*');
} }
} }
} }
@@ -10,7 +10,9 @@ import sanitize from 'sanitize-filename';
export const assetUploadOption: MulterOptions = { export const assetUploadOption: MulterOptions = {
fileFilter: (req: Request, file: any, cb: any) => { fileFilter: (req: Request, file: any, cb: any) => {
if (file.mimetype.match(/\/(jpg|jpeg|png|gif|mp4|x-msvideo|quicktime|heic|heif|dng|x-adobe-dng|webp|tiff|3gpp)$/)) { if (
file.mimetype.match(/\/(jpg|jpeg|png|gif|mp4|x-msvideo|quicktime|heic|heif|dng|x-adobe-dng|webp|tiff|3gpp|nef)$/)
) {
cb(null, true); cb(null, true);
} else { } else {
cb(new HttpException(`Unsupported file type ${extname(file.originalname)}`, HttpStatus.BAD_REQUEST), false); cb(new HttpException(`Unsupported file type ${extname(file.originalname)}`, HttpStatus.BAD_REQUEST), false);
@@ -39,8 +41,8 @@ export const assetUploadOption: MulterOptions = {
filename: (req: Request, file: Express.Multer.File, cb: any) => { filename: (req: Request, file: Express.Multer.File, cb: any) => {
const fileNameUUID = randomUUID(); const fileNameUUID = randomUUID();
const fileName = `${fileNameUUID}${req.body['fileExtension'].toLowerCase()}`; const fileName = `${fileNameUUID}${req.body['fileExtension'].toLowerCase()}`;
const sanitizedFileName = sanitize(fileName);
cb(null, sanitize(fileName)); cb(null, sanitizedFileName);
}, },
}), }),
}; };
@@ -10,7 +10,7 @@ export interface IServerVersion {
export const serverVersion: IServerVersion = { export const serverVersion: IServerVersion = {
major: 1, major: 1,
minor: 29, minor: 30,
patch: 2, patch: 2,
build: 43, build: 48,
}; };
@@ -8,14 +8,16 @@ import { Queue } from 'bull';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { ExifEntity } from '@app/database/entities/exif.entity'; import { ExifEntity } from '@app/database/entities/exif.entity';
import { import {
exifExtractionProcessorName,
generateWEBPThumbnailProcessorName,
IMetadataExtractionJob, IMetadataExtractionJob,
IVideoTranscodeJob, IVideoTranscodeJob,
metadataExtractionQueueName, metadataExtractionQueueName,
thumbnailGeneratorQueueName,
videoConversionQueueName,
generateWEBPThumbnailProcessorName,
mp4ConversionProcessorName, mp4ConversionProcessorName,
reverseGeocodingProcessorName, reverseGeocodingProcessorName,
thumbnailGeneratorQueueName,
videoConversionQueueName,
videoMetadataExtractionProcessorName,
} from '@app/job'; } from '@app/job';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
@@ -80,11 +82,11 @@ export class ScheduleTasksService {
} }
} }
@Cron(CronExpression.EVERY_5_SECONDS) @Cron(CronExpression.EVERY_DAY_AT_2AM)
async reverseGeocoding() { async reverseGeocoding() {
const isMapboxEnable = this.configService.get('ENABLE_MAPBOX'); const isGeocodingEnabled = this.configService.get('DISABLE_REVERSE_GEOCODING') !== 'true';
if (isMapboxEnable) { if (isGeocodingEnabled) {
const exifInfo = await this.exifRepository.find({ const exifInfo = await this.exifRepository.find({
where: { where: {
city: IsNull(), city: IsNull(),
@@ -94,7 +96,37 @@ export class ScheduleTasksService {
}); });
for (const exif of exifInfo) { for (const exif of exifInfo) {
await this.metadataExtractionQueue.add(reverseGeocodingProcessorName, { exif }, { jobId: randomUUID() }); await this.metadataExtractionQueue.add(
reverseGeocodingProcessorName,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
{ exifId: exif.id, latitude: exif.latitude!, longitude: exif.longitude! },
{ jobId: randomUUID() },
);
}
}
}
@Cron(CronExpression.EVERY_DAY_AT_3AM)
async extractExif() {
const exifAssets = await this.assetRepository.find({
where: {
exifInfo: IsNull(),
},
});
for (const asset of exifAssets) {
if (asset.type === AssetType.VIDEO) {
await this.metadataExtractionQueue.add(
videoMetadataExtractionProcessorName,
{ asset, fileName: asset.id },
{ jobId: randomUUID() },
);
} else {
await this.metadataExtractionQueue.add(
exifExtractionProcessorName,
{ asset, fileName: asset.id },
{ jobId: randomUUID() },
);
} }
} }
} }
@@ -42,13 +42,18 @@ export class AssetUploadedProcessor {
*/ */
@Process(assetUploadedProcessorName) @Process(assetUploadedProcessorName)
async processUploadedVideo(job: Job<IAssetUploadedJob>) { async processUploadedVideo(job: Job<IAssetUploadedJob>) {
const { asset, fileName, fileSize } = job.data; const { asset, fileName } = job.data;
await this.thumbnailGeneratorQueue.add(generateJPEGThumbnailProcessorName, { asset }, { jobId: randomUUID() }); await this.thumbnailGeneratorQueue.add(generateJPEGThumbnailProcessorName, { asset }, { jobId: randomUUID() });
// Video Conversion // Video Conversion
if (asset.type == AssetType.VIDEO) { if (asset.type == AssetType.VIDEO) {
await this.videoConversionQueue.add(mp4ConversionProcessorName, { asset }, { jobId: randomUUID() }); await this.videoConversionQueue.add(mp4ConversionProcessorName, { asset }, { jobId: randomUUID() });
await this.metadataExtractionQueue.add(
videoMetadataExtractionProcessorName,
{ asset, fileName },
{ jobId: randomUUID() },
);
} else { } else {
// Extract Metadata/Exif for Images - Currently the EXIF library on the web cannot extract EXIF for video yet // Extract Metadata/Exif for Images - Currently the EXIF library on the web cannot extract EXIF for video yet
await this.metadataExtractionQueue.add( await this.metadataExtractionQueue.add(
@@ -56,19 +61,9 @@ export class AssetUploadedProcessor {
{ {
asset, asset,
fileName, fileName,
fileSize,
}, },
{ jobId: randomUUID() }, { jobId: randomUUID() },
); );
} }
// Extract video duration if uploaded from the web & CLI
if (asset.type == AssetType.VIDEO) {
await this.metadataExtractionQueue.add(
videoMetadataExtractionProcessorName,
{ asset, fileName, fileSize },
{ jobId: randomUUID() }
);
}
} }
} }
@@ -13,8 +13,6 @@ import {
reverseGeocodingProcessorName, reverseGeocodingProcessorName,
IReverseGeocodingProcessor, IReverseGeocodingProcessor,
} from '@app/job'; } from '@app/job';
import { MapiResponse } from '@mapbox/mapbox-sdk/lib/classes/mapi-response';
import mapboxGeocoding, { GeocodeService } from '@mapbox/mapbox-sdk/services/geocoding';
import { Process, Processor } from '@nestjs/bull'; import { Process, Processor } from '@nestjs/bull';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
@@ -26,10 +24,64 @@ import ffmpeg from 'fluent-ffmpeg';
import path from 'path'; import path from 'path';
import sharp from 'sharp'; import sharp from 'sharp';
import { Repository } from 'typeorm/repository/Repository'; import { Repository } from 'typeorm/repository/Repository';
import geocoder, { InitOptions } from 'local-reverse-geocoder';
import { getName } from 'i18n-iso-countries';
import { find } from 'geo-tz';
import * as luxon from 'luxon';
import fs from 'node:fs';
function geocoderInit(init: InitOptions) {
return new Promise<void>(function (resolve) {
geocoder.init(init, () => {
resolve();
});
});
}
function geocoderLookup(points: { latitude: number; longitude: number }[]) {
return new Promise<GeoData>(function (resolve) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
geocoder.lookUp(points, 1, (err, addresses) => {
resolve(addresses[0][0]);
});
});
}
const geocodingPrecisionLevels = ['cities15000', 'cities5000', 'cities1000', 'cities500'];
export interface AdminCode {
name: string;
asciiName: string;
geoNameId: string;
}
export interface GeoData {
geoNameId: string;
name: string;
asciiName: string;
alternateNames: string;
latitude: string;
longitude: string;
featureClass: string;
featureCode: string;
countryCode: string;
cc2?: any;
admin1Code?: AdminCode;
admin2Code?: AdminCode;
admin3Code?: any;
admin4Code?: any;
population: string;
elevation: string;
dem: string;
timezone: string;
modificationDate: string;
distance: number;
}
@Processor(metadataExtractionQueueName) @Processor(metadataExtractionQueueName)
export class MetadataExtractionProcessor { export class MetadataExtractionProcessor {
private geocodingClient?: GeocodeService; private isGeocodeInitialized = false;
private logLevel: ImmichLogLevel; private logLevel: ImmichLogLevel;
constructor( constructor(
@@ -44,19 +96,52 @@ export class MetadataExtractionProcessor {
private configService: ConfigService, private configService: ConfigService,
) { ) {
if (process.env.ENABLE_MAPBOX == 'true' && process.env.MAPBOX_KEY) { if (configService.get('DISABLE_REVERSE_GEOCODING') !== 'true') {
this.geocodingClient = mapboxGeocoding({ Logger.log('Initialising Reverse Geocoding');
accessToken: process.env.MAPBOX_KEY, geocoderInit({
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
citiesFileOverride: geocodingPrecisionLevels[configService.get('REVERSE_GEOCODING_PRECISION')],
load: {
admin1: true,
admin2: true,
admin3And4: false,
alternateNames: false,
},
countries: [],
dumpDirectory: configService.get('REVERSE_GEOCODING_DUMP_DIRECTORY') || (process.cwd() + '/.reverse-geocoding-dump/'),
}).then(() => {
this.isGeocodeInitialized = true;
Logger.log('Reverse Geocoding Initialised');
}); });
} }
this.logLevel = this.configService.get('LOG_LEVEL') || ImmichLogLevel.SIMPLE; this.logLevel = this.configService.get('LOG_LEVEL') || ImmichLogLevel.SIMPLE;
} }
private async reverseGeocodeExif(
latitude: number,
longitude: number,
): Promise<{ country: string; state: string; city: string }> {
const geoCodeInfo = await geocoderLookup([{ latitude, longitude }]);
const country = getName(geoCodeInfo.countryCode, 'en');
const city = geoCodeInfo.name;
let state = '';
if (geoCodeInfo.admin2Code?.name) state += geoCodeInfo.admin2Code.name;
if (geoCodeInfo.admin1Code?.name) {
if (geoCodeInfo.admin2Code?.name) state += ', ';
state += geoCodeInfo.admin1Code.name;
}
return { country, state, city };
}
@Process(exifExtractionProcessorName) @Process(exifExtractionProcessorName)
async extractExifInfo(job: Job<IExifExtractionProcessor>) { async extractExifInfo(job: Job<IExifExtractionProcessor>) {
try { try {
const { asset, fileName, fileSize }: { asset: AssetEntity; fileName: string; fileSize: number } = job.data; const { asset, fileName }: { asset: AssetEntity; fileName: string } = job.data;
const exifData = await exifr.parse(asset.originalPath, { const exifData = await exifr.parse(asset.originalPath, {
tiff: true, tiff: true,
ifd0: true as any, ifd0: true as any,
@@ -75,6 +160,11 @@ export class MetadataExtractionProcessor {
throw new Error(`can not parse exif data from file ${asset.originalPath}`); throw new Error(`can not parse exif data from file ${asset.originalPath}`);
} }
const createdAt = new Date(exifData.DateTimeOriginal || exifData.CreateDate || new Date(asset.createdAt));
const fileStats = fs.statSync(asset.originalPath);
const fileSizeInBytes = fileStats.size;
const newExif = new ExifEntity(); const newExif = new ExifEntity();
newExif.assetId = asset.id; newExif.assetId = asset.id;
newExif.make = exifData['Make'] || null; newExif.make = exifData['Make'] || null;
@@ -82,9 +172,9 @@ export class MetadataExtractionProcessor {
newExif.imageName = path.parse(fileName).name || null; newExif.imageName = path.parse(fileName).name || null;
newExif.exifImageHeight = exifData['ExifImageHeight'] || exifData['ImageHeight'] || null; newExif.exifImageHeight = exifData['ExifImageHeight'] || exifData['ImageHeight'] || null;
newExif.exifImageWidth = exifData['ExifImageWidth'] || exifData['ImageWidth'] || null; newExif.exifImageWidth = exifData['ExifImageWidth'] || exifData['ImageWidth'] || null;
newExif.fileSizeInByte = fileSize || null; newExif.fileSizeInByte = fileSizeInBytes || null;
newExif.orientation = exifData['Orientation'] || null; newExif.orientation = exifData['Orientation'] || null;
newExif.dateTimeOriginal = new Date(asset.createdAt) || null; newExif.dateTimeOriginal = createdAt;
newExif.modifyDate = exifData['ModifyDate'] || null; newExif.modifyDate = exifData['ModifyDate'] || null;
newExif.lensModel = exifData['LensModel'] || null; newExif.lensModel = exifData['LensModel'] || null;
newExif.fNumber = exifData['FNumber'] || null; newExif.fNumber = exifData['FNumber'] || null;
@@ -94,39 +184,60 @@ export class MetadataExtractionProcessor {
newExif.latitude = exifData['latitude'] || null; newExif.latitude = exifData['latitude'] || null;
newExif.longitude = exifData['longitude'] || null; newExif.longitude = exifData['longitude'] || null;
// Reverse GeoCoding /**
if (this.geocodingClient && exifData['longitude'] && exifData['latitude']) { * Correctly store UTC time based on timezone
const geoCodeInfo: MapiResponse = await this.geocodingClient * The timestamp being extracted from EXIF is based on the timezone
.reverseGeocode({ * of the container. We need to correct it to UTC time based on the
query: [exifData['longitude'], exifData['latitude']], * timezone of the location.
types: ['country', 'region', 'place'], *
}) * The timezone of the location can be exracted from the lat/lon
.send(); * GPS coordinates.
*
* Any assets that doesn't have this information will used the
* createdAt timestamp of the asset instead.
*
* The updated/corrected timestamp will be used to update the
* createdAt timestamp in the asset table. So that the information
* is consistent across the database.
* */
if (newExif.longitude && newExif.latitude) {
const tz = find(newExif.latitude, newExif.longitude)[0];
const localTimeWithTimezone = createdAt.toISOString();
const res: [] = geoCodeInfo.body['features']; if (localTimeWithTimezone.length == 24) {
// Remove the last character
let city = ''; const localTimeWithoutTimezone = localTimeWithTimezone.slice(0, -1);
let state = ''; const correctUTCTime = luxon.DateTime.fromISO(localTimeWithoutTimezone, { zone: tz }).toUTC().toISO();
let country = ''; newExif.dateTimeOriginal = new Date(correctUTCTime);
await this.assetRepository.save({
if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'place')[0]) { id: asset.id,
city = res.filter((geoInfo) => geoInfo['place_type'][0] == 'place')[0]['text']; createdAt: correctUTCTime,
});
} }
} else {
if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'region')[0]) { await this.assetRepository.save({
state = res.filter((geoInfo) => geoInfo['place_type'][0] == 'region')[0]['text']; id: asset.id,
} createdAt: createdAt.toISOString(),
});
if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'country')[0]) {
country = res.filter((geoInfo) => geoInfo['place_type'][0] == 'country')[0]['text'];
}
newExif.city = city || null;
newExif.state = state || null;
newExif.country = country || null;
} }
// Enrich metadata /**
* Reverse Geocoding
*
* Get the city, state or region name of the asset
* based on lat/lon GPS coordinates.
*/
if (this.isGeocodeInitialized && newExif.latitude && newExif.longitude) {
const { country, state, city } = await this.reverseGeocodeExif(newExif.latitude, newExif.longitude);
newExif.country = country;
newExif.state = state;
newExif.city = city;
}
/**
* IF the EXIF doesn't contain the width and height of the image,
* We will use Sharpjs to get the information.
*/
if (!newExif.exifImageHeight || !newExif.exifImageWidth || !newExif.orientation) { if (!newExif.exifImageHeight || !newExif.exifImageWidth || !newExif.orientation) {
const metadata = await sharp(asset.originalPath).metadata(); const metadata = await sharp(asset.originalPath).metadata();
@@ -155,35 +266,10 @@ export class MetadataExtractionProcessor {
@Process({ name: reverseGeocodingProcessorName }) @Process({ name: reverseGeocodingProcessorName })
async reverseGeocoding(job: Job<IReverseGeocodingProcessor>) { async reverseGeocoding(job: Job<IReverseGeocodingProcessor>) {
const { exif } = job.data; if (this.isGeocodeInitialized) {
const { latitude, longitude } = job.data;
if (this.geocodingClient) { const { country, state, city } = await this.reverseGeocodeExif(latitude, longitude);
const geoCodeInfo: MapiResponse = await this.geocodingClient await this.exifRepository.update({ id: job.data.exifId }, { city, state, country });
.reverseGeocode({
query: [Number(exif.longitude), Number(exif.latitude)],
types: ['country', 'region', 'place'],
})
.send();
const res: [] = geoCodeInfo.body['features'];
let city = '';
let state = '';
let country = '';
if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'place')[0]) {
city = res.filter((geoInfo) => geoInfo['place_type'][0] == 'place')[0]['text'];
}
if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'region')[0]) {
state = res.filter((geoInfo) => geoInfo['place_type'][0] == 'region')[0]['text'];
}
if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'country')[0]) {
country = res.filter((geoInfo) => geoInfo['place_type'][0] == 'country')[0]['text'];
}
await this.exifRepository.update({ id: exif.id }, { city, state, country });
} }
} }
@@ -295,35 +381,11 @@ export class MetadataExtractionProcessor {
} }
// Reverse GeoCoding // Reverse GeoCoding
if (this.geocodingClient && newExif.longitude && newExif.latitude) { if (this.isGeocodeInitialized && newExif.longitude && newExif.latitude) {
const geoCodeInfo: MapiResponse = await this.geocodingClient const { country, state, city } = await this.reverseGeocodeExif(newExif.latitude, newExif.longitude);
.reverseGeocode({ newExif.country = country;
query: [newExif.longitude, newExif.latitude], newExif.state = state;
types: ['country', 'region', 'place'], newExif.city = city;
})
.send();
const res: [] = geoCodeInfo.body['features'];
let city = '';
let state = '';
let country = '';
if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'place')[0]) {
city = res.filter((geoInfo) => geoInfo['place_type'][0] == 'place')[0]['text'];
}
if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'region')[0]) {
state = res.filter((geoInfo) => geoInfo['place_type'][0] == 'region')[0]['text'];
}
if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'country')[0]) {
country = res.filter((geoInfo) => geoInfo['place_type'][0] == 'country')[0]['text'];
}
newExif.city = city || null;
newExif.state = state || null;
newExif.country = country || null;
} }
for (const stream of data.streams) { for (const stream of data.streams) {
@@ -62,7 +62,7 @@ export class ThumbnailGeneratorProcessor {
const temp = asset.originalPath.split('/'); const temp = asset.originalPath.split('/');
const originalFilename = temp[temp.length - 1].split('.')[0]; const originalFilename = temp[temp.length - 1].split('.')[0];
const jpegThumbnailPath = resizePath + originalFilename + '.jpeg'; const jpegThumbnailPath = join(resizePath, `${originalFilename}.jpeg`);
if (asset.type == AssetType.IMAGE) { if (asset.type == AssetType.IMAGE) {
try { try {
+2 -6
View File
@@ -10,12 +10,8 @@ export const immichAppConfig: ConfigModuleOptions = {
DB_PASSWORD: Joi.string().required(), DB_PASSWORD: Joi.string().required(),
DB_DATABASE_NAME: Joi.string().required(), DB_DATABASE_NAME: Joi.string().required(),
JWT_SECRET: Joi.string().required(), JWT_SECRET: Joi.string().required(),
ENABLE_MAPBOX: Joi.boolean().required().valid(true, false), DISABLE_REVERSE_GEOCODING: Joi.boolean().optional().valid(true, false).default(false),
MAPBOX_KEY: Joi.any().when('ENABLE_MAPBOX', { REVERSE_GEOCODING_PRECISION: Joi.number().optional().valid(0,1,2,3).default(3),
is: false,
then: Joi.string().optional().allow(null, ''),
otherwise: Joi.string().required(),
}),
LOG_LEVEL: Joi.string().optional().valid('simple', 'verbose').default('simple'), LOG_LEVEL: Joi.string().optional().valid('simple', 'verbose').default('simple'),
}), }),
}; };
@@ -10,9 +10,4 @@ export interface IAssetUploadedJob {
* Original file name * Original file name
*/ */
fileName: string; fileName: string;
/**
* File size in byte
*/
fileSize: number;
} }
@@ -1,5 +1,4 @@
import { AssetEntity } from '@app/database/entities/asset.entity'; import { AssetEntity } from '@app/database/entities/asset.entity';
import { ExifEntity } from '@app/database/entities/exif.entity';
export interface IExifExtractionProcessor { export interface IExifExtractionProcessor {
/** /**
@@ -11,11 +10,6 @@ export interface IExifExtractionProcessor {
* Original file name * Original file name
*/ */
fileName: string; fileName: string;
/**
* File size in byte
*/
fileSize: number;
} }
export interface IVideoLengthExtractionProcessor { export interface IVideoLengthExtractionProcessor {
@@ -28,18 +22,12 @@ export interface IVideoLengthExtractionProcessor {
* Original file name * Original file name
*/ */
fileName: string; fileName: string;
/**
* File size in byte
*/
fileSize: number;
} }
export interface IReverseGeocodingProcessor { export interface IReverseGeocodingProcessor {
/** exifId: string;
* The Asset entity that was saved in the database latitude: number;
*/ longitude: number;
exif: ExifEntity;
} }
export type IMetadataExtractionJob = export type IMetadataExtractionJob =
+1105 -1196
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -28,7 +28,6 @@
"api:generate": "npm run api:typescript && npm run api:dart" "api:generate": "npm run api:typescript && npm run api:dart"
}, },
"dependencies": { "dependencies": {
"@mapbox/mapbox-sdk": "^0.13.3",
"@nestjs/bull": "^0.5.5", "@nestjs/bull": "^0.5.5",
"@nestjs/common": "^8.4.7", "@nestjs/common": "^8.4.7",
"@nestjs/config": "^2.1.0", "@nestjs/config": "^2.1.0",
@@ -53,8 +52,12 @@
"dotenv": "^14.2.0", "dotenv": "^14.2.0",
"exifr": "^7.1.3", "exifr": "^7.1.3",
"fluent-ffmpeg": "^2.1.2", "fluent-ffmpeg": "^2.1.2",
"geo-tz": "^7.0.2",
"i18n-iso-countries": "^7.5.0",
"joi": "^17.5.0", "joi": "^17.5.0",
"local-reverse-geocoder": "^0.12.2",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"luxon": "^3.0.3",
"passport": "^0.6.0", "passport": "^0.6.0",
"passport-jwt": "^4.0.0", "passport-jwt": "^4.0.0",
"pg": "^8.7.1", "pg": "^8.7.1",
@@ -83,7 +86,6 @@
"@types/imagemin": "^8.0.0", "@types/imagemin": "^8.0.0",
"@types/jest": "27.0.2", "@types/jest": "27.0.2",
"@types/lodash": "^4.14.178", "@types/lodash": "^4.14.178",
"@types/mapbox__mapbox-sdk": "^0.13.4",
"@types/multer": "^1.4.7", "@types/multer": "^1.4.7",
"@types/node": "^16.0.0", "@types/node": "^16.0.0",
"@types/passport-jwt": "^3.0.6", "@types/passport-jwt": "^3.0.6",
@@ -150,7 +150,7 @@
</div> </div>
<div <div
class={`row-start-2 row-span-end col-start-1 col-span-2 flex place-items-center hover:cursor-pointer w-3/4 ${ class={`row-start-2 row-span-end col-start-1 col-span-2 flex place-items-center hover:cursor-pointer w-3/4 mb-[60px] ${
asset.type === AssetTypeEnum.Video ? '' : 'z-[999]' asset.type === AssetTypeEnum.Video ? '' : 'z-[999]'
}`} }`}
on:mouseenter={() => { on:mouseenter={() => {
@@ -182,7 +182,7 @@
</div> </div>
<div <div
class={`row-start-2 row-span-full col-start-3 col-span-2 flex justify-end place-items-center hover:cursor-pointer w-3/4 justify-self-end ${ class={`row-start-2 row-span-full col-start-3 col-span-2 flex justify-end place-items-center hover:cursor-pointer w-3/4 justify-self-end mb-[60px] ${
asset.type === AssetTypeEnum.Video ? '' : 'z-[500]' asset.type === AssetTypeEnum.Video ? '' : 'z-[500]'
}`} }`}
on:click={navigateAssetForward} on:click={navigateAssetForward}
@@ -195,7 +195,7 @@
}} }}
> >
<button <button
class="rounded-full p-3 hover:bg-gray-500 hover:text-gray-700 text-gray-500 mx-4 z-[1000]" class="rounded-full p-3 hover:bg-gray-500 hover:text-gray-700 text-gray-500 mx-4"
class:navigation-button-hover={halfRightHover} class:navigation-button-hover={halfRightHover}
on:click={navigateAssetForward} on:click={navigateAssetForward}
> >
@@ -7,7 +7,6 @@
import moment from 'moment'; import moment from 'moment';
import { createEventDispatcher, onMount } from 'svelte'; import { createEventDispatcher, onMount } from 'svelte';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { env } from '$env/dynamic/public';
import { AssetResponseDto, AlbumResponseDto } from '@api'; import { AssetResponseDto, AlbumResponseDto } from '@api';
type Leaflet = typeof import('leaflet'); type Leaflet = typeof import('leaflet');
@@ -31,13 +30,6 @@
if (asset.exifInfo?.latitude != null && asset.exifInfo?.longitude != null) { if (asset.exifInfo?.latitude != null && asset.exifInfo?.longitude != null) {
await drawMap(asset.exifInfo.latitude, asset.exifInfo.longitude); await drawMap(asset.exifInfo.latitude, asset.exifInfo.longitude);
} }
// remove timezone when user not config PUBLIC_TZ var. Etc/UTC is used in default.
if (asset.exifInfo?.dateTimeOriginal && !env.PUBLIC_TZ) {
const dateTimeOriginal = asset.exifInfo.dateTimeOriginal;
asset.exifInfo.dateTimeOriginal = dateTimeOriginal.slice(0, dateTimeOriginal.length - 1);
}
} }
}); });
+1 -1
View File
@@ -31,7 +31,7 @@ export const openFileUploadDialog = (uploadType: UploadType) => {
fileSelector.type = 'file'; fileSelector.type = 'file';
fileSelector.multiple = true; fileSelector.multiple = true;
fileSelector.accept = 'image/*,video/*,.heic,.heif,.dng,.3gp'; fileSelector.accept = 'image/*,video/*,.heic,.heif,.dng,.3gp,.nef';
fileSelector.onchange = async (e: Event) => { fileSelector.onchange = async (e: Event) => {
const target = e.target as HTMLInputElement; const target = e.target as HTMLInputElement;
+1
View File
@@ -12,6 +12,7 @@ export const load: PageServerLoad = async ({ parent }) => {
user user
}; };
} catch (e) { } catch (e) {
console.log('Photo page load error', e);
throw redirect(302, '/auth/login'); throw redirect(302, '/auth/login');
} }
}; };