Compare commits
25 Commits
v1.20.3_30
...
v1.22.0_32
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c1d1dd5a1 | ||
|
|
1580d27c23 | ||
|
|
4b9187928c | ||
|
|
5b7236f6ad | ||
|
|
6fb439b580 | ||
|
|
a8334b5c27 | ||
|
|
e1cac93945 | ||
|
|
081f9f5bce | ||
|
|
25ccc5660d | ||
|
|
b6d3e578f2 | ||
|
|
52377c2dcf | ||
|
|
5c78f707fe | ||
|
|
bd5ed1b684 | ||
|
|
e89339b813 | ||
|
|
0b69feda40 | ||
|
|
339f7f776f | ||
|
|
7e6ccbad21 | ||
|
|
aac53e5cdc | ||
|
|
cbec75a175 | ||
|
|
bf04d9eb39 | ||
|
|
3058c894b1 | ||
|
|
e57e279fe1 | ||
|
|
f43c58fc6d | ||
|
|
dea304ac39 | ||
|
|
b46e834220 |
@@ -27,7 +27,7 @@ jobs:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Build and push Immich Mono Repo
|
||||
uses: docker/build-push-action@v3.1.0
|
||||
uses: docker/build-push-action@v3.1.1
|
||||
with:
|
||||
context: ./server
|
||||
file: ./server/Dockerfile
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Build and Push Machine Learning
|
||||
uses: docker/build-push-action@v3.1.0
|
||||
uses: docker/build-push-action@v3.1.1
|
||||
with:
|
||||
context: ./machine-learning
|
||||
file: ./machine-learning/Dockerfile
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Build and Push Web
|
||||
uses: docker/build-push-action@v3.1.0
|
||||
uses: docker/build-push-action@v3.1.1
|
||||
with:
|
||||
context: ./web
|
||||
file: ./web/Dockerfile
|
||||
@@ -110,7 +110,7 @@ jobs:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Build and Push Proxy
|
||||
uses: docker/build-push-action@v3.1.0
|
||||
uses: docker/build-push-action@v3.1.1
|
||||
with:
|
||||
context: ./nginx
|
||||
file: ./nginx/Dockerfile
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Build and push Immich Mono Repo
|
||||
uses: docker/build-push-action@v3.1.0
|
||||
uses: docker/build-push-action@v3.1.1
|
||||
with:
|
||||
context: ./server
|
||||
file: ./server/Dockerfile
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Build and Push Machine Learning
|
||||
uses: docker/build-push-action@v3.1.0
|
||||
uses: docker/build-push-action@v3.1.1
|
||||
with:
|
||||
context: ./machine-learning
|
||||
file: ./machine-learning/Dockerfile
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Build and Push Web
|
||||
uses: docker/build-push-action@v3.1.0
|
||||
uses: docker/build-push-action@v3.1.1
|
||||
with:
|
||||
context: ./web
|
||||
file: ./web/Dockerfile
|
||||
@@ -116,7 +116,7 @@ jobs:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Build and Push Proxy
|
||||
uses: docker/build-push-action@v3.1.0
|
||||
uses: docker/build-push-action@v3.1.1
|
||||
with:
|
||||
context: ./nginx
|
||||
file: ./nginx/Dockerfile
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push immich-server release
|
||||
uses: docker/build-push-action@v3.1.0
|
||||
uses: docker/build-push-action@v3.1.1
|
||||
with:
|
||||
context: ./server
|
||||
file: ./server/Dockerfile
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Build and Push Machine Learning
|
||||
uses: docker/build-push-action@v3.1.0
|
||||
uses: docker/build-push-action@v3.1.1
|
||||
with:
|
||||
context: ./machine-learning
|
||||
file: ./machine-learning/Dockerfile
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push immich-web release
|
||||
uses: docker/build-push-action@v3.1.0
|
||||
uses: docker/build-push-action@v3.1.1
|
||||
with:
|
||||
context: ./web
|
||||
file: ./web/Dockerfile
|
||||
@@ -147,7 +147,7 @@ jobs:
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push immich-proxy release
|
||||
uses: docker/build-push-action@v3.1.0
|
||||
uses: docker/build-push-action@v3.1.1
|
||||
with:
|
||||
context: ./nginx
|
||||
file: ./nginx/Dockerfile
|
||||
|
||||
18
.github/workflows/test.yml
vendored
18
.github/workflows/test.yml
vendored
@@ -2,11 +2,12 @@ name: Test
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
push: { branches: master }
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test-server-e2e:
|
||||
name: Run test suite
|
||||
e2e-tests:
|
||||
name: Run end-to-end test suites
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -16,3 +17,14 @@ jobs:
|
||||
|
||||
- name: Run Immich Server 2E2 Test
|
||||
run: docker-compose -f ./docker/docker-compose.test.yml --env-file ./docker/.env.test up --abort-on-container-exit --exit-code-from immich-server-test
|
||||
|
||||
unit-tests:
|
||||
name: Run unit test suites
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Run tests
|
||||
run: cd server && npm install && npm run test
|
||||
|
||||
3
Makefile
3
Makefile
@@ -1,6 +1,9 @@
|
||||
dev:
|
||||
rm -rf ./server/dist && docker-compose -f ./docker/docker-compose.dev.yml up --remove-orphans
|
||||
|
||||
dev-new:
|
||||
rm -rf ./server/dist && docker compose -f ./docker/docker-compose.dev.yml up --remove-orphans
|
||||
|
||||
dev-update:
|
||||
rm -rf ./server/dist && docker-compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ wget https://raw.githubusercontent.com/immich-app/immich/main/docker/docker-comp
|
||||
Get `.env`
|
||||
|
||||
```bash
|
||||
wget -O .env wget https://raw.githubusercontent.com/immich-app/immich/main/docker/.env.example
|
||||
wget -O .env https://raw.githubusercontent.com/immich-app/immich/main/docker/.env.example
|
||||
```
|
||||
|
||||
### Step 2 - Populate .env file with customed information
|
||||
|
||||
@@ -30,8 +30,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 30,
|
||||
"android.injected.version.name" => "1.20.0",
|
||||
"android.injected.version.code" => 32,
|
||||
"android.injected.version.name" => "1.22.0",
|
||||
}
|
||||
)
|
||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
||||
|
||||
@@ -15,13 +15,21 @@ For _fastlane_ installation instructions, see [Installing _fastlane_](https://do
|
||||
|
||||
## Android
|
||||
|
||||
### android build
|
||||
|
||||
```sh
|
||||
[bundle exec] fastlane android build
|
||||
```
|
||||
|
||||
Build Android
|
||||
|
||||
### android release
|
||||
|
||||
```sh
|
||||
[bundle exec] fastlane android release
|
||||
```
|
||||
|
||||
Update AAB to PlayStore
|
||||
Build and Release Android
|
||||
|
||||
----
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
* Improve performance
|
||||
* Fix album title overflow
|
||||
* New feature - Share asset from mobile to other apps
|
||||
@@ -0,0 +1 @@
|
||||
* Modify Album API endpoint to return count attribute instead of all assets to reduce network consumption and CPU processing.
|
||||
@@ -5,17 +5,17 @@
|
||||
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000204">
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000221">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="11.673502">
|
||||
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="55.750133">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="37.162935">
|
||||
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="35.558064">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
"backup_info_card_assets": "Elemente",
|
||||
"control_bottom_app_bar_delete": "Löschen",
|
||||
"create_shared_album_page_share": "Teilen",
|
||||
"create_shared_album_page_create": "Erstellen",
|
||||
"create_shared_album_page_share_add_assets": "ELEMENTE HINZUFÜGEN",
|
||||
"create_shared_album_page_share_select_photos": "Fotos auswählen",
|
||||
"daily_title_text_date": "E, dd MMM",
|
||||
@@ -97,10 +98,19 @@
|
||||
"tab_controller_nav_photos": "Fotos",
|
||||
"tab_controller_nav_search": "Suche",
|
||||
"tab_controller_nav_sharing": "Teilen",
|
||||
"tab_controller_nav_library": "Bibliothek",
|
||||
"version_announcement_overlay_ack": "Ich habe verstanden",
|
||||
"version_announcement_overlay_release_notes": "Änderungsprotokoll",
|
||||
"version_announcement_overlay_text_1": "Hallo mein Freund! Es gibt eine neue Version von",
|
||||
"version_announcement_overlay_text_2": "Bitte nehm dir die Zeit und lese das ",
|
||||
"version_announcement_overlay_text_3": " und achte darauf, dass deine docker-compose und .env Dateien aktuell sind, vor allem wenn du ein System für automatische Updates benutzt (z.B. Watchtower).",
|
||||
"version_announcement_overlay_title": "Neue Server-Version verfügbar \uD83C\uDF89"
|
||||
"version_announcement_overlay_title": "Neue Server-Version verfügbar \uD83C\uDF89",
|
||||
"album_thumbnail_card_item": "1 Element",
|
||||
"album_thumbnail_card_items": "{} Elemente",
|
||||
"album_thumbnail_card_shared": " · Geteilt",
|
||||
"library_page_albums": "Alben",
|
||||
"library_page_new_album": "Neues Album",
|
||||
"create_album_page_untitled": "Unbenannt",
|
||||
"share_dialog_preparing": "Vorbereiten...",
|
||||
"control_bottom_app_bar_share": "Teilen"
|
||||
}
|
||||
|
||||
@@ -104,5 +104,13 @@
|
||||
"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_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",
|
||||
"album_thumbnail_card_item": "1 item",
|
||||
"album_thumbnail_card_items": "{} items",
|
||||
"album_thumbnail_card_shared": " · Shared",
|
||||
"library_page_albums": "Albums",
|
||||
"library_page_new_album": "New album",
|
||||
"create_album_page_untitled": "Untitled",
|
||||
"share_dialog_preparing": "Preparing...",
|
||||
"control_bottom_app_bar_share": "Share"
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ PODS:
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- SAMKeychain (1.5.3)
|
||||
- share_plus (0.0.1):
|
||||
- Flutter
|
||||
- shared_preferences_ios (0.0.1):
|
||||
- Flutter
|
||||
- sqflite (0.0.2):
|
||||
@@ -40,6 +42,7 @@ DEPENDENCIES:
|
||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||
- path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`)
|
||||
- photo_manager (from `.symlinks/plugins/photo_manager/ios`)
|
||||
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||
- shared_preferences_ios (from `.symlinks/plugins/shared_preferences_ios/ios`)
|
||||
- sqflite (from `.symlinks/plugins/sqflite/ios`)
|
||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||
@@ -67,6 +70,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/path_provider_ios/ios"
|
||||
photo_manager:
|
||||
:path: ".symlinks/plugins/photo_manager/ios"
|
||||
share_plus:
|
||||
:path: ".symlinks/plugins/share_plus/ios"
|
||||
shared_preferences_ios:
|
||||
:path: ".symlinks/plugins/shared_preferences_ios/ios"
|
||||
sqflite:
|
||||
@@ -88,6 +93,7 @@ SPEC CHECKSUMS:
|
||||
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
|
||||
photo_manager: 4f6810b7dfc4feb03b461ac1a70dacf91fba7604
|
||||
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
|
||||
share_plus: 056a1e8ac890df3e33cb503afffaf1e9b4fbae68
|
||||
shared_preferences_ios: 548a61f8053b9b8a49ac19c1ffbc8b92c50d68ad
|
||||
sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904
|
||||
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
|
||||
|
||||
@@ -360,7 +360,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 38;
|
||||
CURRENT_PROJECT_VERSION = 40;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@@ -495,7 +495,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 38;
|
||||
CURRENT_PROJECT_VERSION = 40;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@@ -522,7 +522,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 38;
|
||||
CURRENT_PROJECT_VERSION = 40;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.20.0</string>
|
||||
<string>1.21.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>38</string>
|
||||
<string>40</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true />
|
||||
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
|
||||
|
||||
@@ -19,7 +19,7 @@ platform :ios do
|
||||
desc "iOS Beta"
|
||||
lane :beta do
|
||||
increment_version_number(
|
||||
version_number: "1.20.0"
|
||||
version_number: "1.22.0"
|
||||
)
|
||||
increment_build_number(
|
||||
build_number: latest_testflight_build_number + 1,
|
||||
|
||||
@@ -5,34 +5,32 @@
|
||||
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000213">
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000205">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="2.088407">
|
||||
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.360401">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="22.635867">
|
||||
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="4.012696">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.376681">
|
||||
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.378836">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="4: build_app" time="91.762747">
|
||||
<testcase classname="fastlane.lanes" name="4: build_app" time="80.023705">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="49.149884">
|
||||
|
||||
<failure message="/opt/homebrew/Cellar/fastlane/2.207.0/libexec/gems/fastlane-2.207.0/fastlane/lib/fastlane/actions/actions_helper.rb:67:in `execute_action' /opt/homebrew/Cellar/fastlane/2.207.0/libexec/gems/fastlane-2.207.0/fastlane/lib/fastlane/runner.rb:255:in `block in execute_action' /opt/homebrew/Cellar/fastlane/2.207.0/libexec/gems/fastlane-2.207.0/fastlane/lib/fastlane/runner.rb:229:in `chdir' /opt/homebrew/Cellar/fastlane/2.207.0/libexec/gems/fastlane-2.207.0/fastlane/lib/fastlane/runner.rb:229:in `execute_action' /opt/homebrew/Cellar/fastlane/2.207.0/libexec/gems/fastlane-2.207.0/fastlane/lib/fastlane/runner.rb:157:in `trigger_action_by_name' /opt/homebrew/Cellar/fastlane/2.207.0/libexec/gems/fastlane-2.207.0/fastlane/lib/fastlane/fast_file.rb:159:in `method_missing' Fastfile:30:in `block (2 levels) in parsing_binding' /opt/homebrew/Cellar/fastlane/2.207.0/libexec/gems/fastlane-2.207.0/fastlane/lib/fastlane/lane.rb:33:in `call' /opt/homebrew/Cellar/fastlane/2.207.0/libexec/gems/fastlane-2.207.0/fastlane/lib/fastlane/runner.rb:49:in `block in execute' /opt/homebrew/Cellar/fastlane/2.207.0/libexec/gems/fastlane-2.207.0/fastlane/lib/fastlane/runner.rb:45:in `chdir' /opt/homebrew/Cellar/fastlane/2.207.0/libexec/gems/fastlane-2.207.0/fastlane/lib/fastlane/runner.rb:45:in `execute' /opt/homebrew/Cellar/fastlane/2.207.0/libexec/gems/fastlane-2.207.0/fastlane/lib/fastlane/lane_manager.rb:47:in `cruise_lane' /opt/homebrew/Cellar/fastlane/2.207.0/libexec/gems/fastlane-2.207.0/fastlane/lib/fastlane/command_line_handler.rb:36:in `handle' /opt/homebrew/Cellar/fastlane/2.207.0/libexec/gems/fastlane-2.207.0/fastlane/lib/fastlane/commands_generator.rb:110:in `block (2 levels) in run' /opt/homebrew/Cellar/fastlane/2.207.0/libexec/gems/commander-4.6.0/lib/commander/command.rb:187:in `call' /opt/homebrew/Cellar/fastlane/2.207.0/libexec/gems/commander-4.6.0/lib/commander/command.rb:157:in `run' /opt/homebrew/Cellar/fastlane/2.207.0/libexec/gems/commander-4.6.0/lib/commander/runner.rb:444:in `run_active_command' /opt/homebrew/Cellar/fastlane/2.207.0/libexec/gems/fastlane-2.207.0/fastlane_core/lib/fastlane_core/ui/fastlane_runner.rb:124:in `run!' /opt/homebrew/Cellar/fastlane/2.207.0/libexec/gems/commander-4.6.0/lib/commander/delegates.rb:18:in `run!' /opt/homebrew/Cellar/fastlane/2.207.0/libexec/gems/fastlane-2.207.0/fastlane/lib/fastlane/commands_generator.rb:354:in `run' /opt/homebrew/Cellar/fastlane/2.207.0/libexec/gems/fastlane-2.207.0/fastlane/lib/fastlane/commands_generator.rb:43:in `start' /opt/homebrew/Cellar/fastlane/2.207.0/libexec/gems/fastlane-2.207.0/fastlane/lib/fastlane/cli_tools_distributor.rb:123:in `take_off' /opt/homebrew/Cellar/fastlane/2.207.0/libexec/gems/fastlane-2.207.0/bin/fastlane:23:in `<top (required)>' /opt/homebrew/Cellar/fastlane/2.207.0/libexec/bin/fastlane:25:in `load' /opt/homebrew/Cellar/fastlane/2.207.0/libexec/bin/fastlane:25:in `<main>' Error uploading ipa file: [Transporter Error Output]: ERROR ITMS-90186: Invalid Pre-Release Train. The train version '1.19.0' is closed for new build submissions
|
||||
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="98.18403">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_displaymode/flutter_displaymode.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/immich_colors.dart';
|
||||
@@ -49,6 +53,14 @@ void main() async {
|
||||
Locale('it', 'IT'),
|
||||
];
|
||||
|
||||
if (kReleaseMode && Platform.isAndroid) {
|
||||
try {
|
||||
await FlutterDisplayMode.setHighRefreshRate();
|
||||
} catch (e) {
|
||||
debugPrint("Error setting high refresh rate: $e");
|
||||
}
|
||||
}
|
||||
|
||||
runApp(
|
||||
EasyLocalization(
|
||||
supportedLocales: locales,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
@@ -15,6 +16,8 @@ class AlbumThumbnailCard extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
var box = Hive.box(userInfoBox);
|
||||
|
||||
final cardSize = MediaQuery.of(context).size.width / 2 - 18;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
AutoRouter.of(context).push(AlbumViewerRoute(albumId: album.id));
|
||||
@@ -27,8 +30,8 @@ class AlbumThumbnailCard extends StatelessWidget {
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: FadeInImage(
|
||||
width: MediaQuery.of(context).size.width / 2 - 18,
|
||||
height: MediaQuery.of(context).size.width / 2 - 18,
|
||||
width: cardSize,
|
||||
height: cardSize,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: MemoryImage(kTransparentImage),
|
||||
image: NetworkImage(
|
||||
@@ -43,11 +46,14 @@ class AlbumThumbnailCard extends StatelessWidget {
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Text(
|
||||
album.albumName,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
child: SizedBox(
|
||||
width: cardSize,
|
||||
child: Text(
|
||||
album.albumName,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -55,18 +61,20 @@ class AlbumThumbnailCard extends StatelessWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'${album.assets.length} item${album.assets.length > 1 ? 's' : ''}',
|
||||
album.assetCount == 1
|
||||
? 'album_thumbnail_card_item'
|
||||
: 'album_thumbnail_card_items',
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
).tr(args: ['${album.assetCount}']),
|
||||
if (album.shared)
|
||||
const Text(
|
||||
' · Shared',
|
||||
'album_thumbnail_card_shared',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
),
|
||||
)
|
||||
).tr()
|
||||
],
|
||||
)
|
||||
],
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class AlbumViewerThumbnail extends HookConsumerWidget {
|
||||
@@ -24,8 +25,7 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final cacheKey = useState(1);
|
||||
var box = Hive.box(userInfoBox);
|
||||
var thumbnailRequestUrl =
|
||||
'${box.get(serverEndpointKey)}/asset/thumbnail/${asset.id}';
|
||||
var thumbnailRequestUrl = getThumbnailUrl(asset);
|
||||
var deviceId = ref.watch(authenticationProvider).deviceId;
|
||||
final selectedAssetsInAlbumViewer =
|
||||
ref.watch(assetSelectionProvider).selectedAssetsInAlbumViewer;
|
||||
@@ -37,7 +37,6 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
|
||||
GalleryViewerRoute(
|
||||
asset: asset,
|
||||
assetList: assetList,
|
||||
thumbnailRequestUrl: thumbnailRequestUrl,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class SharedAlbumThumbnailImage extends HookConsumerWidget {
|
||||
@@ -17,8 +18,6 @@ class SharedAlbumThumbnailImage extends HookConsumerWidget {
|
||||
final cacheKey = useState(1);
|
||||
|
||||
var box = Hive.box(userInfoBox);
|
||||
var thumbnailRequestUrl =
|
||||
'${box.get(serverEndpointKey)}/asset/thumbnail/${asset.id}';
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
@@ -32,7 +31,7 @@ class SharedAlbumThumbnailImage extends HookConsumerWidget {
|
||||
height: 500,
|
||||
memCacheHeight: 500,
|
||||
fit: BoxFit.cover,
|
||||
imageUrl: thumbnailRequestUrl,
|
||||
imageUrl: getThumbnailUrl(asset),
|
||||
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
|
||||
fadeInDuration: const Duration(milliseconds: 250),
|
||||
progressIndicatorBuilder: (context, url, downloadProgress) =>
|
||||
|
||||
@@ -203,7 +203,7 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
assetList: albumInfo.assets,
|
||||
);
|
||||
},
|
||||
childCount: albumInfo.assets.length,
|
||||
childCount: albumInfo.assetCount,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -37,8 +37,10 @@ class CreateAlbumPage extends HookConsumerWidget {
|
||||
isAlbumTitleTextFieldFocus.value = false;
|
||||
|
||||
if (albumTitleController.text.isEmpty) {
|
||||
albumTitleController.text = 'Untitled';
|
||||
ref.watch(albumTitleProvider.notifier).setAlbumTitle('Untitled');
|
||||
albumTitleController.text = 'create_album_page_untitled'.tr();
|
||||
ref
|
||||
.watch(albumTitleProvider.notifier)
|
||||
.setAlbumTitle('create_album_page_untitled'.tr());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
@@ -66,15 +67,15 @@ class LibraryPage extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 8.0),
|
||||
child: Text(
|
||||
"New album",
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: const Text(
|
||||
'library_page_new_album',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
).tr(),
|
||||
)
|
||||
],
|
||||
),
|
||||
@@ -85,13 +86,13 @@ class LibraryPage extends HookConsumerWidget {
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
_buildAppBar(),
|
||||
const SliverToBoxAdapter(
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(12.0),
|
||||
child: Text(
|
||||
"Albums",
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: const Text(
|
||||
'library_page_albums',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
).tr(),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/services/image_viewer.service.dart';
|
||||
import 'package:immich_mobile/shared/services/share.service.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
import 'package:immich_mobile/shared/ui/share_dialog.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class ImageViewerStateNotifier extends StateNotifier<ImageViewerPageState> {
|
||||
final ImageViewerService _imageViewerService;
|
||||
final ShareService _shareService;
|
||||
|
||||
ImageViewerStateNotifier(this._imageViewerService)
|
||||
ImageViewerStateNotifier(this._imageViewerService, this._shareService)
|
||||
: super(
|
||||
ImageViewerPageState(
|
||||
downloadAssetStatus: DownloadAssetStatus.idle,
|
||||
@@ -42,9 +46,23 @@ class ImageViewerStateNotifier extends StateNotifier<ImageViewerPageState> {
|
||||
|
||||
state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.idle);
|
||||
}
|
||||
|
||||
void shareAsset(AssetResponseDto asset, BuildContext context) async {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext buildContext) {
|
||||
_shareService
|
||||
.shareAsset(asset)
|
||||
.then((_) => Navigator.of(buildContext).pop());
|
||||
return const ShareDialog();
|
||||
},
|
||||
barrierDismissible: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final imageViewerStateProvider =
|
||||
StateNotifierProvider<ImageViewerStateNotifier, ImageViewerPageState>(
|
||||
((ref) => ImageViewerStateNotifier(ref.watch(imageViewerServiceProvider))),
|
||||
((ref) => ImageViewerStateNotifier(
|
||||
ref.watch(imageViewerServiceProvider), ref.watch(shareServiceProvider))),
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:photo_view/photo_view.dart';
|
||||
|
||||
enum _RemoteImageStatus { empty, thumbnail, full }
|
||||
enum _RemoteImageStatus { empty, thumbnail, preview, full }
|
||||
|
||||
class _RemotePhotoViewState extends State<RemotePhotoView> {
|
||||
late CachedNetworkImageProvider _imageProvider;
|
||||
@@ -15,13 +15,16 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
bool allowMoving = _status == _RemoteImageStatus.full;
|
||||
return PhotoView(
|
||||
imageProvider: _imageProvider,
|
||||
minScale: PhotoViewComputedScale.contained,
|
||||
maxScale: allowMoving ? 1.0 : PhotoViewComputedScale.contained,
|
||||
enablePanAlways: true,
|
||||
scaleStateChangedCallback: _scaleStateChanged,
|
||||
onScaleEnd: _onScaleListener,
|
||||
|
||||
return IgnorePointer(
|
||||
ignoring: !allowMoving,
|
||||
child: PhotoView(
|
||||
imageProvider: _imageProvider,
|
||||
minScale: PhotoViewComputedScale.contained,
|
||||
enablePanAlways: true,
|
||||
scaleStateChangedCallback: _scaleStateChanged,
|
||||
onScaleEnd: _onScaleListener,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,6 +55,14 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
|
||||
widget.isZoomedFunction();
|
||||
}
|
||||
|
||||
void _fireStartLoadingEvent() {
|
||||
if (widget.onLoadingStart != null) widget.onLoadingStart!();
|
||||
}
|
||||
|
||||
void _fireFinishedLoadingEvent() {
|
||||
if (widget.onLoadingCompleted != null) widget.onLoadingCompleted!();
|
||||
}
|
||||
|
||||
CachedNetworkImageProvider _authorizedImageProvider(String url) {
|
||||
return CachedNetworkImageProvider(
|
||||
url,
|
||||
@@ -64,14 +75,25 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
|
||||
_RemoteImageStatus newStatus,
|
||||
CachedNetworkImageProvider provider,
|
||||
) {
|
||||
// Transition to same status is forbidden
|
||||
if (_status == newStatus) return;
|
||||
// Transition full -> thumbnail is forbidden
|
||||
|
||||
if (_status == _RemoteImageStatus.full &&
|
||||
newStatus == _RemoteImageStatus.thumbnail) return;
|
||||
|
||||
if (_status == _RemoteImageStatus.preview &&
|
||||
newStatus == _RemoteImageStatus.thumbnail) return;
|
||||
|
||||
if (_status == _RemoteImageStatus.full &&
|
||||
newStatus == _RemoteImageStatus.preview) return;
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (newStatus != _RemoteImageStatus.full) {
|
||||
_fireStartLoadingEvent();
|
||||
} else {
|
||||
_fireFinishedLoadingEvent();
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_status = newStatus;
|
||||
_imageProvider = provider;
|
||||
@@ -92,6 +114,16 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
|
||||
}),
|
||||
);
|
||||
|
||||
if (widget.previewUrl != null) {
|
||||
CachedNetworkImageProvider previewProvider =
|
||||
_authorizedImageProvider(widget.previewUrl!);
|
||||
previewProvider.resolve(const ImageConfiguration()).addListener(
|
||||
ImageStreamListener((ImageInfo imageInfo, _) {
|
||||
_performStateTransition(_RemoteImageStatus.preview, previewProvider);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
CachedNetworkImageProvider fullProvider =
|
||||
_authorizedImageProvider(widget.imageUrl);
|
||||
fullProvider.resolve(const ImageConfiguration()).addListener(
|
||||
@@ -109,20 +141,26 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
|
||||
}
|
||||
|
||||
class RemotePhotoView extends StatefulWidget {
|
||||
const RemotePhotoView({
|
||||
Key? key,
|
||||
required this.thumbnailUrl,
|
||||
required this.imageUrl,
|
||||
required this.authToken,
|
||||
required this.isZoomedFunction,
|
||||
required this.isZoomedListener,
|
||||
required this.onSwipeDown,
|
||||
required this.onSwipeUp,
|
||||
}) : super(key: key);
|
||||
const RemotePhotoView(
|
||||
{Key? key,
|
||||
required this.thumbnailUrl,
|
||||
required this.imageUrl,
|
||||
required this.authToken,
|
||||
required this.isZoomedFunction,
|
||||
required this.isZoomedListener,
|
||||
required this.onSwipeDown,
|
||||
required this.onSwipeUp,
|
||||
this.previewUrl,
|
||||
this.onLoadingCompleted,
|
||||
this.onLoadingStart})
|
||||
: super(key: key);
|
||||
|
||||
final String thumbnailUrl;
|
||||
final String imageUrl;
|
||||
final String authToken;
|
||||
final String? previewUrl;
|
||||
final Function? onLoadingCompleted;
|
||||
final Function? onLoadingStart;
|
||||
|
||||
final void Function() onSwipeDown;
|
||||
final void Function() onSwipeUp;
|
||||
|
||||
@@ -11,11 +11,15 @@ class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
|
||||
required this.asset,
|
||||
required this.onMoreInfoPressed,
|
||||
required this.onDownloadPressed,
|
||||
required this.onSharePressed,
|
||||
this.loading = false
|
||||
}) : super(key: key);
|
||||
|
||||
final AssetResponseDto asset;
|
||||
final Function onMoreInfoPressed;
|
||||
final Function onDownloadPressed;
|
||||
final Function onSharePressed;
|
||||
final bool loading;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -35,6 +39,14 @@ class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
if (loading) Center(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 15.0),
|
||||
width: iconSize,
|
||||
height: iconSize,
|
||||
child: const CircularProgressIndicator(strokeWidth: 2.0),
|
||||
),
|
||||
) ,
|
||||
IconButton(
|
||||
iconSize: iconSize,
|
||||
splashRadius: iconSize,
|
||||
@@ -53,6 +65,14 @@ class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
|
||||
? const Icon(Icons.favorite_rounded)
|
||||
: const Icon(Icons.favorite_border_rounded),
|
||||
),
|
||||
IconButton(
|
||||
iconSize: iconSize,
|
||||
splashRadius: iconSize,
|
||||
onPressed: () {
|
||||
onSharePressed();
|
||||
},
|
||||
icon: const Icon(Icons.share),
|
||||
),
|
||||
IconButton(
|
||||
iconSize: iconSize,
|
||||
splashRadius: iconSize,
|
||||
|
||||
@@ -17,13 +17,13 @@ import 'package:openapi/api.dart';
|
||||
class GalleryViewerPage extends HookConsumerWidget {
|
||||
late List<AssetResponseDto> assetList;
|
||||
final AssetResponseDto asset;
|
||||
final String thumbnailRequestUrl;
|
||||
|
||||
static const _threeStageLoading = false;
|
||||
|
||||
GalleryViewerPage({
|
||||
Key? key,
|
||||
required this.assetList,
|
||||
required this.asset,
|
||||
required this.thumbnailRequestUrl,
|
||||
}) : super(key: key);
|
||||
|
||||
AssetResponseDto? assetDetail;
|
||||
@@ -32,6 +32,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
final Box<dynamic> box = Hive.box(userInfoBox);
|
||||
|
||||
int indexOfAsset = assetList.indexOf(asset);
|
||||
final loading = useState(false);
|
||||
|
||||
@override
|
||||
void initState(int index) {
|
||||
@@ -74,6 +75,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: TopControlAppBar(
|
||||
loading: loading.value,
|
||||
asset: assetList[indexOfAsset],
|
||||
onMoreInfoPressed: () {
|
||||
showInfo();
|
||||
@@ -82,6 +84,10 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
ref
|
||||
.watch(imageViewerStateProvider.notifier)
|
||||
.downloadAsset(assetList[indexOfAsset], context);
|
||||
}, onSharePressed: () {
|
||||
ref
|
||||
.watch(imageViewerStateProvider.notifier)
|
||||
.shareAsset(assetList[indexOfAsset], context);
|
||||
},
|
||||
),
|
||||
body: SafeArea(
|
||||
@@ -98,15 +104,14 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
getAssetExif();
|
||||
if (assetList[index].type == AssetTypeEnum.IMAGE) {
|
||||
return ImageViewerPage(
|
||||
thumbnailUrl:
|
||||
'${box.get(serverEndpointKey)}/asset/thumbnail/${assetList[index].id}',
|
||||
imageUrl:
|
||||
'${box.get(serverEndpointKey)}/asset/file?aid=${assetList[index].deviceAssetId}&did=${assetList[index].deviceId}&isThumb=false',
|
||||
authToken: 'Bearer ${box.get(accessTokenKey)}',
|
||||
isZoomedFunction: isZoomedMethod,
|
||||
isZoomedListener: isZoomedListener,
|
||||
onLoadingCompleted: () => loading.value = false,
|
||||
onLoadingStart: () => loading.value = _threeStageLoading,
|
||||
asset: assetList[index],
|
||||
heroTag: assetList[index].id,
|
||||
threeStageLoading: _threeStageLoading
|
||||
);
|
||||
} else {
|
||||
return SwipeDetector(
|
||||
|
||||
@@ -8,27 +8,30 @@ import 'package:immich_mobile/modules/asset_viewer/ui/download_loading_indicator
|
||||
import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/ui/remote_photo_view.dart';
|
||||
import 'package:immich_mobile/modules/home/services/asset.service.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
// ignore: must_be_immutable
|
||||
class ImageViewerPage extends HookConsumerWidget {
|
||||
final String imageUrl;
|
||||
final String heroTag;
|
||||
final String thumbnailUrl;
|
||||
final AssetResponseDto asset;
|
||||
final String authToken;
|
||||
final ValueNotifier<bool> isZoomedListener;
|
||||
final void Function() isZoomedFunction;
|
||||
final void Function() onLoadingCompleted;
|
||||
final void Function() onLoadingStart;
|
||||
final bool threeStageLoading;
|
||||
|
||||
ImageViewerPage({
|
||||
Key? key,
|
||||
required this.imageUrl,
|
||||
required this.heroTag,
|
||||
required this.thumbnailUrl,
|
||||
required this.asset,
|
||||
required this.authToken,
|
||||
required this.isZoomedFunction,
|
||||
required this.isZoomedListener,
|
||||
required this.onLoadingCompleted,
|
||||
required this.onLoadingStart,
|
||||
required this.threeStageLoading,
|
||||
}) : super(key: key);
|
||||
|
||||
AssetResponseDto? assetDetail;
|
||||
@@ -68,14 +71,18 @@ class ImageViewerPage extends HookConsumerWidget {
|
||||
child: Hero(
|
||||
tag: heroTag,
|
||||
child: RemotePhotoView(
|
||||
thumbnailUrl: thumbnailUrl,
|
||||
imageUrl: imageUrl,
|
||||
authToken: authToken,
|
||||
isZoomedFunction: isZoomedFunction,
|
||||
isZoomedListener: isZoomedListener,
|
||||
onSwipeDown: () => AutoRouter.of(context).pop(),
|
||||
onSwipeUp: () => showInfo(),
|
||||
),
|
||||
thumbnailUrl: getThumbnailUrl(asset),
|
||||
imageUrl: getImageUrl(asset),
|
||||
previewUrl: threeStageLoading
|
||||
? getThumbnailUrl(asset, type: ThumbnailFormat.JPEG)
|
||||
: null,
|
||||
authToken: authToken,
|
||||
isZoomedFunction: isZoomedFunction,
|
||||
isZoomedListener: isZoomedListener,
|
||||
onSwipeDown: () => AutoRouter.of(context).pop(),
|
||||
onSwipeUp: () => showInfo(),
|
||||
onLoadingCompleted: onLoadingCompleted,
|
||||
onLoadingStart: onLoadingStart),
|
||||
),
|
||||
),
|
||||
if (downloadAssetStatus == DownloadAssetStatus.loading)
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/home/models/home_page_state.model.dart';
|
||||
import 'package:immich_mobile/shared/services/share.service.dart';
|
||||
import 'package:immich_mobile/shared/ui/share_dialog.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class HomePageStateNotifier extends StateNotifier<HomePageState> {
|
||||
HomePageStateNotifier()
|
||||
|
||||
final ShareService _shareService;
|
||||
|
||||
HomePageStateNotifier(this._shareService)
|
||||
: super(
|
||||
HomePageState(
|
||||
isMultiSelectEnable: false,
|
||||
@@ -64,9 +71,22 @@ class HomePageStateNotifier extends StateNotifier<HomePageState> {
|
||||
|
||||
state = state.copyWith(selectedItems: currentList);
|
||||
}
|
||||
|
||||
void shareAssets(List<AssetResponseDto> assets, BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext buildContext) {
|
||||
_shareService
|
||||
.shareAssets(assets)
|
||||
.then((_) => Navigator.of(buildContext).pop());
|
||||
return const ShareDialog();
|
||||
},
|
||||
barrierDismissible: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final homePageStateProvider =
|
||||
StateNotifierProvider<HomePageStateNotifier, HomePageState>(
|
||||
((ref) => HomePageStateNotifier()),
|
||||
((ref) => HomePageStateNotifier(ref.watch(shareServiceProvider))),
|
||||
);
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
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/ui/delete_diaglog.dart';
|
||||
|
||||
class ControlBottomAppBar extends StatelessWidget {
|
||||
import '../../../shared/providers/asset.provider.dart';
|
||||
import '../providers/home_page_state.provider.dart';
|
||||
|
||||
class ControlBottomAppBar extends ConsumerWidget {
|
||||
const ControlBottomAppBar({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
@@ -25,7 +29,7 @@ class ControlBottomAppBar extends StatelessWidget {
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 20),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
ControlBoxButton(
|
||||
iconData: Icons.delete_forever_rounded,
|
||||
@@ -39,6 +43,20 @@ class ControlBottomAppBar extends StatelessWidget {
|
||||
);
|
||||
},
|
||||
),
|
||||
ControlBoxButton(
|
||||
iconData: Icons.share,
|
||||
label: "control_bottom_app_bar_share".tr(),
|
||||
onPressed: () {
|
||||
final homePageState = ref.watch(homePageStateProvider);
|
||||
ref.watch(homePageStateProvider.notifier).shareAssets(
|
||||
homePageState.selectedItems.toList(),
|
||||
context,
|
||||
);
|
||||
ref
|
||||
.watch(homePageStateProvider.notifier)
|
||||
.disableMultiSelect();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
@@ -67,7 +85,7 @@ class ControlBoxButton extends StatelessWidget {
|
||||
width: 60,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class ThumbnailImage extends HookConsumerWidget {
|
||||
@@ -23,8 +24,7 @@ class ThumbnailImage extends HookConsumerWidget {
|
||||
final cacheKey = useState(1);
|
||||
|
||||
var box = Hive.box(userInfoBox);
|
||||
var thumbnailRequestUrl =
|
||||
'${box.get(serverEndpointKey)}/asset/thumbnail/${asset.id}';
|
||||
var thumbnailRequestUrl = getThumbnailUrl(asset);
|
||||
var selectedAsset = ref.watch(homePageStateProvider).selectedItems;
|
||||
var isMultiSelectEnable =
|
||||
ref.watch(homePageStateProvider).isMultiSelectEnable;
|
||||
@@ -65,7 +65,6 @@ class ThumbnailImage extends HookConsumerWidget {
|
||||
AutoRouter.of(context).push(
|
||||
GalleryViewerRoute(
|
||||
assetList: assetList,
|
||||
thumbnailRequestUrl: thumbnailRequestUrl,
|
||||
asset: asset,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -76,6 +76,7 @@ class HomePage extends HookConsumerWidget {
|
||||
|
||||
imageGridGroup.add(
|
||||
DailyTitleText(
|
||||
key: Key('${dateGroup.toString()}title'),
|
||||
isoDate: dateGroup,
|
||||
assetGroup: immichAssetList,
|
||||
),
|
||||
|
||||
@@ -46,10 +46,7 @@ class _$AppRouter extends RootStackRouter {
|
||||
return MaterialPageX<dynamic>(
|
||||
routeData: routeData,
|
||||
child: GalleryViewerPage(
|
||||
key: args.key,
|
||||
assetList: args.assetList,
|
||||
asset: args.asset,
|
||||
thumbnailRequestUrl: args.thumbnailRequestUrl));
|
||||
key: args.key, assetList: args.assetList, asset: args.asset));
|
||||
},
|
||||
ImageViewerRoute.name: (routeData) {
|
||||
final args = routeData.argsAs<ImageViewerRouteArgs>();
|
||||
@@ -57,13 +54,14 @@ class _$AppRouter extends RootStackRouter {
|
||||
routeData: routeData,
|
||||
child: ImageViewerPage(
|
||||
key: args.key,
|
||||
imageUrl: args.imageUrl,
|
||||
heroTag: args.heroTag,
|
||||
thumbnailUrl: args.thumbnailUrl,
|
||||
asset: args.asset,
|
||||
authToken: args.authToken,
|
||||
isZoomedFunction: args.isZoomedFunction,
|
||||
isZoomedListener: args.isZoomedListener));
|
||||
isZoomedListener: args.isZoomedListener,
|
||||
onLoadingCompleted: args.onLoadingCompleted,
|
||||
onLoadingStart: args.onLoadingStart,
|
||||
threeStageLoading: args.threeStageLoading));
|
||||
},
|
||||
VideoViewerRoute.name: (routeData) {
|
||||
final args = routeData.argsAs<VideoViewerRouteArgs>();
|
||||
@@ -258,25 +256,18 @@ class GalleryViewerRoute extends PageRouteInfo<GalleryViewerRouteArgs> {
|
||||
GalleryViewerRoute(
|
||||
{Key? key,
|
||||
required List<AssetResponseDto> assetList,
|
||||
required AssetResponseDto asset,
|
||||
required String thumbnailRequestUrl})
|
||||
required AssetResponseDto asset})
|
||||
: super(GalleryViewerRoute.name,
|
||||
path: '/gallery-viewer-page',
|
||||
args: GalleryViewerRouteArgs(
|
||||
key: key,
|
||||
assetList: assetList,
|
||||
asset: asset,
|
||||
thumbnailRequestUrl: thumbnailRequestUrl));
|
||||
key: key, assetList: assetList, asset: asset));
|
||||
|
||||
static const String name = 'GalleryViewerRoute';
|
||||
}
|
||||
|
||||
class GalleryViewerRouteArgs {
|
||||
const GalleryViewerRouteArgs(
|
||||
{this.key,
|
||||
required this.assetList,
|
||||
required this.asset,
|
||||
required this.thumbnailRequestUrl});
|
||||
{this.key, required this.assetList, required this.asset});
|
||||
|
||||
final Key? key;
|
||||
|
||||
@@ -284,11 +275,9 @@ class GalleryViewerRouteArgs {
|
||||
|
||||
final AssetResponseDto asset;
|
||||
|
||||
final String thumbnailRequestUrl;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'GalleryViewerRouteArgs{key: $key, assetList: $assetList, asset: $asset, thumbnailRequestUrl: $thumbnailRequestUrl}';
|
||||
return 'GalleryViewerRouteArgs{key: $key, assetList: $assetList, asset: $asset}';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,24 +286,26 @@ class GalleryViewerRouteArgs {
|
||||
class ImageViewerRoute extends PageRouteInfo<ImageViewerRouteArgs> {
|
||||
ImageViewerRoute(
|
||||
{Key? key,
|
||||
required String imageUrl,
|
||||
required String heroTag,
|
||||
required String thumbnailUrl,
|
||||
required AssetResponseDto asset,
|
||||
required String authToken,
|
||||
required void Function() isZoomedFunction,
|
||||
required ValueNotifier<bool> isZoomedListener})
|
||||
required ValueNotifier<bool> isZoomedListener,
|
||||
required void Function() onLoadingCompleted,
|
||||
required void Function() onLoadingStart,
|
||||
required bool threeStageLoading})
|
||||
: super(ImageViewerRoute.name,
|
||||
path: '/image-viewer-page',
|
||||
args: ImageViewerRouteArgs(
|
||||
key: key,
|
||||
imageUrl: imageUrl,
|
||||
heroTag: heroTag,
|
||||
thumbnailUrl: thumbnailUrl,
|
||||
asset: asset,
|
||||
authToken: authToken,
|
||||
isZoomedFunction: isZoomedFunction,
|
||||
isZoomedListener: isZoomedListener));
|
||||
isZoomedListener: isZoomedListener,
|
||||
onLoadingCompleted: onLoadingCompleted,
|
||||
onLoadingStart: onLoadingStart,
|
||||
threeStageLoading: threeStageLoading));
|
||||
|
||||
static const String name = 'ImageViewerRoute';
|
||||
}
|
||||
@@ -322,22 +313,19 @@ class ImageViewerRoute extends PageRouteInfo<ImageViewerRouteArgs> {
|
||||
class ImageViewerRouteArgs {
|
||||
const ImageViewerRouteArgs(
|
||||
{this.key,
|
||||
required this.imageUrl,
|
||||
required this.heroTag,
|
||||
required this.thumbnailUrl,
|
||||
required this.asset,
|
||||
required this.authToken,
|
||||
required this.isZoomedFunction,
|
||||
required this.isZoomedListener});
|
||||
required this.isZoomedListener,
|
||||
required this.onLoadingCompleted,
|
||||
required this.onLoadingStart,
|
||||
required this.threeStageLoading});
|
||||
|
||||
final Key? key;
|
||||
|
||||
final String imageUrl;
|
||||
|
||||
final String heroTag;
|
||||
|
||||
final String thumbnailUrl;
|
||||
|
||||
final AssetResponseDto asset;
|
||||
|
||||
final String authToken;
|
||||
@@ -346,9 +334,15 @@ class ImageViewerRouteArgs {
|
||||
|
||||
final ValueNotifier<bool> isZoomedListener;
|
||||
|
||||
final void Function() onLoadingCompleted;
|
||||
|
||||
final void Function() onLoadingStart;
|
||||
|
||||
final bool threeStageLoading;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ImageViewerRouteArgs{key: $key, imageUrl: $imageUrl, heroTag: $heroTag, thumbnailUrl: $thumbnailUrl, asset: $asset, authToken: $authToken, isZoomedFunction: $isZoomedFunction, isZoomedListener: $isZoomedListener}';
|
||||
return 'ImageViewerRouteArgs{key: $key, heroTag: $heroTag, asset: $asset, authToken: $authToken, isZoomedFunction: $isZoomedFunction, isZoomedListener: $isZoomedListener, onLoadingCompleted: $onLoadingCompleted, onLoadingStart: $onLoadingStart, threeStageLoading: $threeStageLoading}';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
47
mobile/lib/shared/services/share.service.dart
Normal file
47
mobile/lib/shared/services/share.service.dart
Normal file
@@ -0,0 +1,47 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/shared/providers/api.provider.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'api.service.dart';
|
||||
|
||||
final shareServiceProvider =
|
||||
Provider((ref) => ShareService(ref.watch(apiServiceProvider)));
|
||||
|
||||
class ShareService {
|
||||
final ApiService _apiService;
|
||||
|
||||
ShareService(this._apiService);
|
||||
|
||||
Future<void> shareAsset(AssetResponseDto asset) async {
|
||||
await shareAssets([asset]);
|
||||
}
|
||||
|
||||
Future<void> shareAssets(List<AssetResponseDto> assets) async {
|
||||
final downloadedFilePaths = assets.map((asset) async {
|
||||
final res = await _apiService.assetApi.downloadFileWithHttpInfo(
|
||||
asset.deviceAssetId,
|
||||
asset.deviceId,
|
||||
isThumb: false,
|
||||
isWeb: false,
|
||||
);
|
||||
|
||||
final fileName = p.basename(asset.originalPath);
|
||||
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final tempFile = await File('${tempDir.path}/$fileName').create();
|
||||
tempFile.writeAsBytesSync(res.bodyBytes);
|
||||
|
||||
return tempFile.path;
|
||||
});
|
||||
|
||||
Share.shareFiles(
|
||||
await Future.wait(downloadedFilePaths),
|
||||
sharePositionOrigin: Rect.zero,
|
||||
);
|
||||
}
|
||||
}
|
||||
23
mobile/lib/shared/ui/share_dialog.dart
Normal file
23
mobile/lib/shared/ui/share_dialog.dart
Normal file
@@ -0,0 +1,23 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ShareDialog extends StatelessWidget {
|
||||
const ShareDialog({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 12),
|
||||
child: const Text('share_dialog_preparing')
|
||||
.tr(),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
16
mobile/lib/utils/image_url_builder.dart
Normal file
16
mobile/lib/utils/image_url_builder.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
import '../constants/hive_box.dart';
|
||||
|
||||
String getThumbnailUrl(final AssetResponseDto asset,
|
||||
{ThumbnailFormat type = ThumbnailFormat.WEBP}) {
|
||||
final box = Hive.box(userInfoBox);
|
||||
|
||||
return '${box.get(serverEndpointKey)}/asset/thumbnail/${asset.id}?format=${type.value}';
|
||||
}
|
||||
|
||||
String getImageUrl(final AssetResponseDto asset) {
|
||||
final box = Hive.box(userInfoBox);
|
||||
return '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=false';
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import 'package:openapi/api.dart';
|
||||
## Properties
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
**assetCount** | **int** | |
|
||||
**id** | **String** | |
|
||||
**ownerId** | **String** | |
|
||||
**albumName** | **String** | |
|
||||
|
||||
@@ -13,6 +13,7 @@ part of openapi.api;
|
||||
class AlbumResponseDto {
|
||||
/// Returns a new [AlbumResponseDto] instance.
|
||||
AlbumResponseDto({
|
||||
required this.assetCount,
|
||||
required this.id,
|
||||
required this.ownerId,
|
||||
required this.albumName,
|
||||
@@ -23,6 +24,8 @@ class AlbumResponseDto {
|
||||
this.assets = const [],
|
||||
});
|
||||
|
||||
int assetCount;
|
||||
|
||||
String id;
|
||||
|
||||
String ownerId;
|
||||
@@ -41,6 +44,7 @@ class AlbumResponseDto {
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is AlbumResponseDto &&
|
||||
other.assetCount == assetCount &&
|
||||
other.id == id &&
|
||||
other.ownerId == ownerId &&
|
||||
other.albumName == albumName &&
|
||||
@@ -53,6 +57,7 @@ class AlbumResponseDto {
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(assetCount.hashCode) +
|
||||
(id.hashCode) +
|
||||
(ownerId.hashCode) +
|
||||
(albumName.hashCode) +
|
||||
@@ -63,10 +68,11 @@ class AlbumResponseDto {
|
||||
(assets.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'AlbumResponseDto[id=$id, ownerId=$ownerId, albumName=$albumName, createdAt=$createdAt, albumThumbnailAssetId=$albumThumbnailAssetId, shared=$shared, sharedUsers=$sharedUsers, assets=$assets]';
|
||||
String toString() => 'AlbumResponseDto[assetCount=$assetCount, id=$id, ownerId=$ownerId, albumName=$albumName, createdAt=$createdAt, albumThumbnailAssetId=$albumThumbnailAssetId, shared=$shared, sharedUsers=$sharedUsers, assets=$assets]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final _json = <String, dynamic>{};
|
||||
_json[r'assetCount'] = assetCount;
|
||||
_json[r'id'] = id;
|
||||
_json[r'ownerId'] = ownerId;
|
||||
_json[r'albumName'] = albumName;
|
||||
@@ -101,6 +107,7 @@ class AlbumResponseDto {
|
||||
}());
|
||||
|
||||
return AlbumResponseDto(
|
||||
assetCount: mapValueOfType<int>(json, r'assetCount')!,
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
ownerId: mapValueOfType<String>(json, r'ownerId')!,
|
||||
albumName: mapValueOfType<String>(json, r'albumName')!,
|
||||
@@ -158,6 +165,7 @@ class AlbumResponseDto {
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'assetCount',
|
||||
'id',
|
||||
'ownerId',
|
||||
'albumName',
|
||||
|
||||
@@ -76,72 +76,69 @@ class AssetResponseDto {
|
||||
SmartInfoResponseDto? smartInfo;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is AssetResponseDto &&
|
||||
other.type == type &&
|
||||
other.id == id &&
|
||||
other.deviceAssetId == deviceAssetId &&
|
||||
other.ownerId == ownerId &&
|
||||
other.deviceId == deviceId &&
|
||||
other.originalPath == originalPath &&
|
||||
other.resizePath == resizePath &&
|
||||
other.createdAt == createdAt &&
|
||||
other.modifiedAt == modifiedAt &&
|
||||
other.isFavorite == isFavorite &&
|
||||
other.mimeType == mimeType &&
|
||||
other.duration == duration &&
|
||||
other.webpPath == webpPath &&
|
||||
other.encodedVideoPath == encodedVideoPath &&
|
||||
other.exifInfo == exifInfo &&
|
||||
other.smartInfo == smartInfo;
|
||||
bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto &&
|
||||
other.type == type &&
|
||||
other.id == id &&
|
||||
other.deviceAssetId == deviceAssetId &&
|
||||
other.ownerId == ownerId &&
|
||||
other.deviceId == deviceId &&
|
||||
other.originalPath == originalPath &&
|
||||
other.resizePath == resizePath &&
|
||||
other.createdAt == createdAt &&
|
||||
other.modifiedAt == modifiedAt &&
|
||||
other.isFavorite == isFavorite &&
|
||||
other.mimeType == mimeType &&
|
||||
other.duration == duration &&
|
||||
other.webpPath == webpPath &&
|
||||
other.encodedVideoPath == encodedVideoPath &&
|
||||
other.exifInfo == exifInfo &&
|
||||
other.smartInfo == smartInfo;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(type.hashCode) +
|
||||
(id.hashCode) +
|
||||
(deviceAssetId.hashCode) +
|
||||
(ownerId.hashCode) +
|
||||
(deviceId.hashCode) +
|
||||
(originalPath.hashCode) +
|
||||
(resizePath == null ? 0 : resizePath!.hashCode) +
|
||||
(createdAt.hashCode) +
|
||||
(modifiedAt.hashCode) +
|
||||
(isFavorite.hashCode) +
|
||||
(mimeType == null ? 0 : mimeType!.hashCode) +
|
||||
(duration.hashCode) +
|
||||
(webpPath == null ? 0 : webpPath!.hashCode) +
|
||||
(encodedVideoPath == null ? 0 : encodedVideoPath!.hashCode) +
|
||||
(exifInfo == null ? 0 : exifInfo!.hashCode) +
|
||||
(smartInfo == null ? 0 : smartInfo!.hashCode);
|
||||
// ignore: unnecessary_parenthesis
|
||||
(type.hashCode) +
|
||||
(id.hashCode) +
|
||||
(deviceAssetId.hashCode) +
|
||||
(ownerId.hashCode) +
|
||||
(deviceId.hashCode) +
|
||||
(originalPath.hashCode) +
|
||||
(resizePath == null ? 0 : resizePath!.hashCode) +
|
||||
(createdAt.hashCode) +
|
||||
(modifiedAt.hashCode) +
|
||||
(isFavorite.hashCode) +
|
||||
(mimeType == null ? 0 : mimeType!.hashCode) +
|
||||
(duration.hashCode) +
|
||||
(webpPath == null ? 0 : webpPath!.hashCode) +
|
||||
(encodedVideoPath == null ? 0 : encodedVideoPath!.hashCode) +
|
||||
(exifInfo == null ? 0 : exifInfo!.hashCode) +
|
||||
(smartInfo == null ? 0 : smartInfo!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'AssetResponseDto[type=$type, id=$id, deviceAssetId=$deviceAssetId, ownerId=$ownerId, deviceId=$deviceId, originalPath=$originalPath, resizePath=$resizePath, createdAt=$createdAt, modifiedAt=$modifiedAt, isFavorite=$isFavorite, mimeType=$mimeType, duration=$duration, webpPath=$webpPath, encodedVideoPath=$encodedVideoPath, exifInfo=$exifInfo, smartInfo=$smartInfo]';
|
||||
String toString() => 'AssetResponseDto[type=$type, id=$id, deviceAssetId=$deviceAssetId, ownerId=$ownerId, deviceId=$deviceId, originalPath=$originalPath, resizePath=$resizePath, createdAt=$createdAt, modifiedAt=$modifiedAt, isFavorite=$isFavorite, mimeType=$mimeType, duration=$duration, webpPath=$webpPath, encodedVideoPath=$encodedVideoPath, exifInfo=$exifInfo, smartInfo=$smartInfo]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final _json = <String, dynamic>{};
|
||||
_json[r'type'] = type;
|
||||
_json[r'id'] = id;
|
||||
_json[r'deviceAssetId'] = deviceAssetId;
|
||||
_json[r'ownerId'] = ownerId;
|
||||
_json[r'deviceId'] = deviceId;
|
||||
_json[r'originalPath'] = originalPath;
|
||||
_json[r'type'] = type;
|
||||
_json[r'id'] = id;
|
||||
_json[r'deviceAssetId'] = deviceAssetId;
|
||||
_json[r'ownerId'] = ownerId;
|
||||
_json[r'deviceId'] = deviceId;
|
||||
_json[r'originalPath'] = originalPath;
|
||||
if (resizePath != null) {
|
||||
_json[r'resizePath'] = resizePath;
|
||||
} else {
|
||||
_json[r'resizePath'] = null;
|
||||
}
|
||||
_json[r'createdAt'] = createdAt;
|
||||
_json[r'modifiedAt'] = modifiedAt;
|
||||
_json[r'isFavorite'] = isFavorite;
|
||||
_json[r'createdAt'] = createdAt;
|
||||
_json[r'modifiedAt'] = modifiedAt;
|
||||
_json[r'isFavorite'] = isFavorite;
|
||||
if (mimeType != null) {
|
||||
_json[r'mimeType'] = mimeType;
|
||||
} else {
|
||||
_json[r'mimeType'] = null;
|
||||
}
|
||||
_json[r'duration'] = duration;
|
||||
_json[r'duration'] = duration;
|
||||
if (webpPath != null) {
|
||||
_json[r'webpPath'] = webpPath;
|
||||
} else {
|
||||
@@ -177,10 +174,8 @@ class AssetResponseDto {
|
||||
// Note 2: this code is stripped in release mode!
|
||||
assert(() {
|
||||
requiredKeys.forEach((key) {
|
||||
assert(json.containsKey(key),
|
||||
'Required key "AssetResponseDto[$key]" is missing from JSON.');
|
||||
assert(json[key] != null,
|
||||
'Required key "AssetResponseDto[$key]" has a null value in JSON.');
|
||||
assert(json.containsKey(key), 'Required key "AssetResponseDto[$key]" is missing from JSON.');
|
||||
assert(json[key] != null, 'Required key "AssetResponseDto[$key]" has a null value in JSON.');
|
||||
});
|
||||
return true;
|
||||
}());
|
||||
@@ -207,10 +202,7 @@ class AssetResponseDto {
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<AssetResponseDto>? listFromJson(
|
||||
dynamic json, {
|
||||
bool growable = false,
|
||||
}) {
|
||||
static List<AssetResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <AssetResponseDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
@@ -238,18 +230,12 @@ class AssetResponseDto {
|
||||
}
|
||||
|
||||
// maps a json object with a list of AssetResponseDto-objects as value to a dart map
|
||||
static Map<String, List<AssetResponseDto>> mapListFromJson(
|
||||
dynamic json, {
|
||||
bool growable = false,
|
||||
}) {
|
||||
static Map<String, List<AssetResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<AssetResponseDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = AssetResponseDto.listFromJson(
|
||||
entry.value,
|
||||
growable: growable,
|
||||
);
|
||||
final value = AssetResponseDto.listFromJson(entry.value, growable: growable,);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
@@ -276,3 +262,4 @@ class AssetResponseDto {
|
||||
'encodedVideoPath',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -328,6 +328,13 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.3.0"
|
||||
flutter_displaymode:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_displaymode
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.4.0"
|
||||
flutter_hooks:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -868,6 +875,48 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.27.3"
|
||||
share_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: share_plus
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "4.0.10"
|
||||
share_plus_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: share_plus_linux
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
share_plus_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: share_plus_macos
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.1"
|
||||
share_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: share_plus_platform_interface
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
share_plus_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: share_plus_web
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.1"
|
||||
share_plus_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: share_plus_windows
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.1"
|
||||
shared_preferences:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -2,7 +2,7 @@ name: immich_mobile
|
||||
description: Immich - selfhosted backup media file on mobile phone
|
||||
|
||||
publish_to: "none"
|
||||
version: 1.20.0+30
|
||||
version: 1.22.0+32
|
||||
|
||||
environment:
|
||||
sdk: ">=2.17.0 <3.0.0"
|
||||
@@ -41,6 +41,8 @@ dependencies:
|
||||
http: 0.13.4
|
||||
cancellation_token_http: ^1.1.0
|
||||
easy_localization: ^3.0.1
|
||||
share_plus: ^4.0.10
|
||||
flutter_displaymode: ^0.4.0
|
||||
|
||||
path: ^1.8.1
|
||||
path_provider: ^2.0.11
|
||||
|
||||
@@ -134,21 +134,14 @@ export class AlbumRepository implements IAlbumRepository {
|
||||
.leftJoinAndSelect('album.sharedUsers', 'sharedUser')
|
||||
.leftJoinAndSelect('sharedUser.userInfo', 'userInfo')
|
||||
.where('album.ownerId = :ownerId', { ownerId: userId });
|
||||
// .orWhere((qb) => {
|
||||
// const subQuery = qb
|
||||
// .subQuery()
|
||||
// .select('userAlbum.albumId')
|
||||
// .from(UserAlbumEntity, 'userAlbum')
|
||||
// .where('userAlbum.sharedUserId = :sharedUserId', { sharedUserId: userId })
|
||||
// .getQuery();
|
||||
// return `album.id IN ${subQuery}`;
|
||||
// });
|
||||
}
|
||||
|
||||
// Get information of assets in albums
|
||||
query = query
|
||||
.leftJoinAndSelect('album.assets', 'assets')
|
||||
.leftJoinAndSelect('assets.assetInfo', 'assetInfo')
|
||||
.orderBy('"assetInfo"."createdAt"::timestamptz', 'ASC');
|
||||
|
||||
const albums = await query.getMany();
|
||||
|
||||
albums.sort((a, b) => new Date(b.createdAt).valueOf() - new Date(a.createdAt).valueOf());
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AuthUserDto } from '../../decorators/auth-user.decorator';
|
||||
import { BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
import { AlbumEntity } from '@app/database/entities/album.entity';
|
||||
import { AlbumResponseDto } from './response-dto/album-response.dto';
|
||||
import { AssetAlbumEntity } from '@app/database/entities/asset-album.entity';
|
||||
|
||||
describe('Album service', () => {
|
||||
let sut: AlbumService;
|
||||
@@ -12,7 +13,7 @@ describe('Album service', () => {
|
||||
id: '1111',
|
||||
email: 'auth@test.com',
|
||||
});
|
||||
const albumId = '0001';
|
||||
const albumId = 'f19ab956-4761-41ea-a5d6-bae948308d58';
|
||||
const sharedAlbumOwnerId = '2222';
|
||||
const sharedAlbumSharedAlsoWithId = '3333';
|
||||
const ownedAlbumSharedWithId = '4444';
|
||||
@@ -148,7 +149,7 @@ describe('Album service', () => {
|
||||
|
||||
it('gets an owned album', async () => {
|
||||
const ownerId = authUser.id;
|
||||
const albumId = '0001';
|
||||
const albumId = 'f19ab956-4761-41ea-a5d6-bae948308d58';
|
||||
|
||||
const albumEntity = _getOwnedAlbum();
|
||||
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
@@ -157,11 +158,12 @@ describe('Album service', () => {
|
||||
albumName: 'name',
|
||||
albumThumbnailAssetId: null,
|
||||
createdAt: 'date',
|
||||
id: '0001',
|
||||
id: 'f19ab956-4761-41ea-a5d6-bae948308d58',
|
||||
ownerId,
|
||||
shared: false,
|
||||
assets: [],
|
||||
sharedUsers: [],
|
||||
assetCount: 0,
|
||||
};
|
||||
await expect(sut.getAlbumInfo(authUser, albumId)).resolves.toEqual(expectedResult);
|
||||
});
|
||||
@@ -270,6 +272,7 @@ describe('Album service', () => {
|
||||
authUser,
|
||||
{
|
||||
albumName: updatedAlbumName,
|
||||
albumThumbnailAssetId: updatedAlbumThumbnailAssetId,
|
||||
},
|
||||
albumId,
|
||||
);
|
||||
@@ -279,7 +282,7 @@ describe('Album service', () => {
|
||||
expect(albumRepositoryMock.updateAlbum).toHaveBeenCalledTimes(1);
|
||||
expect(albumRepositoryMock.updateAlbum).toHaveBeenCalledWith(albumEntity, {
|
||||
albumName: updatedAlbumName,
|
||||
thumbnailAssetId: updatedAlbumThumbnailAssetId,
|
||||
albumThumbnailAssetId: updatedAlbumThumbnailAssetId,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -357,45 +360,45 @@ describe('Album service', () => {
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('removes assets from owned album', async () => {
|
||||
const albumEntity = _getOwnedAlbum();
|
||||
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
albumRepositoryMock.removeAssets.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
// it('removes assets from owned album', async () => {
|
||||
// const albumEntity = _getOwnedAlbum();
|
||||
// albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
// albumRepositoryMock.removeAssets.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
|
||||
await expect(
|
||||
sut.removeAssetsFromAlbum(
|
||||
authUser,
|
||||
{
|
||||
assetIds: ['1'],
|
||||
},
|
||||
albumEntity.id,
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
expect(albumRepositoryMock.removeAssets).toHaveBeenCalledTimes(1);
|
||||
expect(albumRepositoryMock.removeAssets).toHaveBeenCalledWith(albumEntity, {
|
||||
assetIds: ['1'],
|
||||
});
|
||||
});
|
||||
// await expect(
|
||||
// sut.removeAssetsFromAlbum(
|
||||
// authUser,
|
||||
// {
|
||||
// assetIds: ['f19ab956-4761-41ea-a5d6-bae948308d60'],
|
||||
// },
|
||||
// albumEntity.id,
|
||||
// ),
|
||||
// ).resolves.toBeUndefined();
|
||||
// expect(albumRepositoryMock.removeAssets).toHaveBeenCalledTimes(1);
|
||||
// expect(albumRepositoryMock.removeAssets).toHaveBeenCalledWith(albumEntity, {
|
||||
// assetIds: ['f19ab956-4761-41ea-a5d6-bae948308d60'],
|
||||
// });
|
||||
// });
|
||||
|
||||
it('removes assets from shared album (shared with auth user)', async () => {
|
||||
const albumEntity = _getOwnedSharedAlbum();
|
||||
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
albumRepositoryMock.removeAssets.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
// it('removes assets from shared album (shared with auth user)', async () => {
|
||||
// const albumEntity = _getOwnedSharedAlbum();
|
||||
// albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
// albumRepositoryMock.removeAssets.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
|
||||
await expect(
|
||||
sut.removeAssetsFromAlbum(
|
||||
authUser,
|
||||
{
|
||||
assetIds: ['1'],
|
||||
},
|
||||
albumEntity.id,
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
expect(albumRepositoryMock.removeAssets).toHaveBeenCalledTimes(1);
|
||||
expect(albumRepositoryMock.removeAssets).toHaveBeenCalledWith(albumEntity, {
|
||||
assetIds: ['1'],
|
||||
});
|
||||
});
|
||||
// await expect(
|
||||
// sut.removeAssetsFromAlbum(
|
||||
// authUser,
|
||||
// {
|
||||
// assetIds: ['1'],
|
||||
// },
|
||||
// albumEntity.id,
|
||||
// ),
|
||||
// ).resolves.toBeUndefined();
|
||||
// expect(albumRepositoryMock.removeAssets).toHaveBeenCalledTimes(1);
|
||||
// expect(albumRepositoryMock.removeAssets).toHaveBeenCalledWith(albumEntity, {
|
||||
// assetIds: ['1'],
|
||||
// });
|
||||
// });
|
||||
|
||||
it('prevents removing assets from a not owned / shared album', async () => {
|
||||
const albumEntity = _getNotOwnedNotSharedAlbum();
|
||||
@@ -414,4 +417,33 @@ describe('Album service', () => {
|
||||
),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('counts assets correctly', async () => {
|
||||
const albumEntity = new AlbumEntity();
|
||||
|
||||
albumEntity.ownerId = authUser.id;
|
||||
albumEntity.id = albumId;
|
||||
albumEntity.albumName = 'name';
|
||||
albumEntity.createdAt = 'date';
|
||||
albumEntity.sharedUsers = [];
|
||||
albumEntity.assets = [
|
||||
{
|
||||
id: '1',
|
||||
albumId: '2',
|
||||
assetId: '3',
|
||||
//@ts-expect-error Partial stub
|
||||
albumInfo: {},
|
||||
//@ts-expect-error Partial stub
|
||||
assetInfo: {},
|
||||
},
|
||||
];
|
||||
albumEntity.albumThumbnailAssetId = null;
|
||||
|
||||
albumRepositoryMock.getList.mockImplementation(() => Promise.resolve([albumEntity]));
|
||||
|
||||
const result = await sut.getAllAlbums(authUser, {});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].assetCount).toEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import { AddUsersDto } from './dto/add-users.dto';
|
||||
import { RemoveAssetsDto } from './dto/remove-assets.dto';
|
||||
import { UpdateAlbumDto } from './dto/update-album.dto';
|
||||
import { GetAlbumsDto } from './dto/get-albums.dto';
|
||||
import { AlbumResponseDto, mapAlbum } from './response-dto/album-response.dto';
|
||||
import { AlbumResponseDto, mapAlbum, mapAlbumExcludeAssetInfo } from './response-dto/album-response.dto';
|
||||
import { ALBUM_REPOSITORY, IAlbumRepository } from './album-repository';
|
||||
|
||||
@Injectable()
|
||||
@@ -49,7 +49,8 @@ export class AlbumService {
|
||||
*/
|
||||
async getAllAlbums(authUser: AuthUserDto, getAlbumsDto: GetAlbumsDto): Promise<AlbumResponseDto[]> {
|
||||
const albums = await this._albumRepository.getList(authUser.id, getAlbumsDto);
|
||||
return albums.map((album) => mapAlbum(album));
|
||||
|
||||
return albums.map((album) => mapAlbumExcludeAssetInfo(album));
|
||||
}
|
||||
|
||||
async getAlbumInfo(authUser: AuthUserDto, albumId: string): Promise<AlbumResponseDto> {
|
||||
@@ -80,8 +81,6 @@ export class AlbumService {
|
||||
await this._albumRepository.removeUser(album, sharedUserId);
|
||||
}
|
||||
|
||||
// async removeUsersFromAlbum() {}
|
||||
|
||||
async removeAssetsFromAlbum(
|
||||
authUser: AuthUserDto,
|
||||
removeAssetsDto: RemoveAssetsDto,
|
||||
@@ -89,7 +88,6 @@ export class AlbumService {
|
||||
): Promise<AlbumResponseDto> {
|
||||
const album = await this._getAlbum({ authUser, albumId });
|
||||
const updateAlbum = await this._albumRepository.removeAssets(album, removeAssetsDto);
|
||||
|
||||
return mapAlbum(updateAlbum);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AlbumEntity } from '../../../../../../libs/database/src/entities/album.entity';
|
||||
import { UserResponseDto, mapUser } from '../../user/response-dto/user-response.dto';
|
||||
import { AssetResponseDto, mapAsset } from '../../asset/response-dto/asset-response.dto';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class AlbumResponseDto {
|
||||
id!: string;
|
||||
@@ -11,6 +12,9 @@ export class AlbumResponseDto {
|
||||
shared!: boolean;
|
||||
sharedUsers!: UserResponseDto[];
|
||||
assets!: AssetResponseDto[];
|
||||
|
||||
@ApiProperty({ type: 'integer' })
|
||||
assetCount!: number;
|
||||
}
|
||||
|
||||
export function mapAlbum(entity: AlbumEntity): AlbumResponseDto {
|
||||
@@ -24,5 +28,21 @@ export function mapAlbum(entity: AlbumEntity): AlbumResponseDto {
|
||||
sharedUsers,
|
||||
shared: sharedUsers.length > 0,
|
||||
assets: entity.assets?.map((assetAlbum) => mapAsset(assetAlbum.assetInfo)) || [],
|
||||
assetCount: entity.assets?.length || 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapAlbumExcludeAssetInfo(entity: AlbumEntity): AlbumResponseDto {
|
||||
const sharedUsers = entity.sharedUsers?.map((userAlbum) => mapUser(userAlbum.userInfo)) || [];
|
||||
return {
|
||||
albumName: entity.albumName,
|
||||
albumThumbnailAssetId: entity.albumThumbnailAssetId,
|
||||
createdAt: entity.createdAt,
|
||||
id: entity.id,
|
||||
ownerId: entity.ownerId,
|
||||
sharedUsers,
|
||||
shared: sharedUsers.length > 0,
|
||||
assets: [],
|
||||
assetCount: entity.assets?.length || 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface IServerVersion {
|
||||
|
||||
export const serverVersion: IServerVersion = {
|
||||
major: 1,
|
||||
minor: 20,
|
||||
patch: 3,
|
||||
minor: 22,
|
||||
patch: 0,
|
||||
build: 0,
|
||||
};
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -90,6 +90,12 @@ export interface AdminSignupResponseDto {
|
||||
* @interface AlbumResponseDto
|
||||
*/
|
||||
export interface AlbumResponseDto {
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
* @memberof AlbumResponseDto
|
||||
*/
|
||||
'assetCount': number;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
|
||||
@@ -6,85 +6,86 @@
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
font-family: 'Work Sans', sans-serif;
|
||||
/* --immich-icon-button-hover-color: #d3d3d3; */
|
||||
font-family: 'Work Sans', sans-serif;
|
||||
/* --immich-icon-button-hover-color: #d3d3d3; */
|
||||
}
|
||||
|
||||
html {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
html::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
/* Track */
|
||||
html::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 16px;
|
||||
background: #f1f1f1;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
/* Handle */
|
||||
html::-webkit-scrollbar-thumb {
|
||||
background: rgba(85, 86, 87, 0.408);
|
||||
border-radius: 16px;
|
||||
background: rgba(85, 86, 87, 0.408);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
/* Handle on hover */
|
||||
html::-webkit-scrollbar-thumb:hover {
|
||||
background: #4250afad;
|
||||
border-radius: 16px;
|
||||
background: #4250afad;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
body {
|
||||
/* min-height: 100vh; */
|
||||
margin: 0;
|
||||
background-color: #f6f8fe;
|
||||
color: #5f6368;
|
||||
/* min-height: 100vh; */
|
||||
margin: 0;
|
||||
background-color: #f6f8fe;
|
||||
color: #5f6368;
|
||||
}
|
||||
|
||||
input:focus-visible {
|
||||
outline-offset: 0px !important;
|
||||
outline: none !important;
|
||||
outline-offset: 0px !important;
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.immich-form-input {
|
||||
@apply bg-slate-100 p-2 rounded-md focus:border-immich-primary text-sm;
|
||||
}
|
||||
.immich-form-input {
|
||||
@apply bg-slate-100 p-2 rounded-md focus:border-immich-primary text-sm ;
|
||||
}
|
||||
|
||||
.immich-form-label {
|
||||
@apply font-medium text-sm text-gray-500;
|
||||
}
|
||||
.immich-form-label {
|
||||
@apply font-medium text-sm text-gray-500;
|
||||
}
|
||||
|
||||
.immich-btn-primary {
|
||||
@apply bg-immich-primary text-gray-100 border rounded-xl py-2 px-4 transition-all duration-150 hover:bg-immich-primary hover:shadow-lg text-sm font-medium;
|
||||
}
|
||||
.immich-btn-primary {
|
||||
@apply bg-immich-primary text-gray-100 border rounded-xl py-2 px-4 transition-all duration-150 hover:bg-immich-primary hover:shadow-lg text-sm font-medium;
|
||||
}
|
||||
|
||||
.immich-text-button {
|
||||
@apply flex place-items-center place-content-center gap-2 hover:bg-immich-primary/5 p-2 rounded-lg font-medium;
|
||||
}
|
||||
.immich-text-button {
|
||||
@apply flex place-items-center place-content-center gap-2 hover:bg-immich-primary/5 p-2 rounded-lg font-medium;
|
||||
}
|
||||
|
||||
/* width */
|
||||
.immich-scrollbar::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
/* width */
|
||||
.immich-scrollbar::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
/* Track */
|
||||
.immich-scrollbar::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 16px;
|
||||
}
|
||||
/* Track */
|
||||
.immich-scrollbar::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
/* Handle */
|
||||
.immich-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(85, 86, 87, 0.408);
|
||||
border-radius: 16px;
|
||||
}
|
||||
/* Handle */
|
||||
.immich-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(85, 86, 87, 0.408);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
/* Handle on hover */
|
||||
.immich-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #4250afad;
|
||||
border-radius: 16px;
|
||||
}
|
||||
/* Handle on hover */
|
||||
.immich-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #4250afad;
|
||||
border-radius: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,9 @@
|
||||
<td class="text-sm px-4 w-1/4 text-ellipsis">{user.lastName}</td>
|
||||
<td class="text-sm px-4 w-1/4 text-ellipsis"
|
||||
><button
|
||||
on:click={() => {
|
||||
dispatch('edit-user', { user });
|
||||
}}
|
||||
class="bg-immich-primary text-gray-100 rounded-full p-3 transition-all duration-150 hover:bg-immich-primary/75"
|
||||
><PencilOutline size="20" /></button
|
||||
></td
|
||||
@@ -40,4 +43,4 @@
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<button on:click={() => dispatch('createUser')} class="immich-btn-primary">Create user</button>
|
||||
<button on:click={() => dispatch('create-user')} class="immich-btn-primary">Create user</button>
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
imageData = await loadHighQualityThumbnail(album.albumThumbnailAssetId) || 'no-thumbnail.png';
|
||||
imageData = (await loadHighQualityThumbnail(album.albumThumbnailAssetId)) || 'no-thumbnail.png';
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
</p>
|
||||
|
||||
<span class="text-xs flex gap-2">
|
||||
<p>{album.assets.length} items</p>
|
||||
<p>{album.assetCount} items</p>
|
||||
|
||||
{#if album.shared}
|
||||
<p>·</p>
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
import ImmichThumbnail from '../shared-components/immich-thumbnail.svelte';
|
||||
import AssetSelection from './asset-selection.svelte';
|
||||
import _ from 'lodash-es';
|
||||
import AlbumAppBar from './album-app-bar.svelte';
|
||||
import UserSelectionModal from './user-selection-modal.svelte';
|
||||
import ShareInfoModal from './share-info-modal.svelte';
|
||||
import CircleIconButton from '../shared-components/circle-icon-button.svelte';
|
||||
@@ -22,6 +21,7 @@
|
||||
import ContextMenu from '../shared-components/context-menu/context-menu.svelte';
|
||||
import MenuOption from '../shared-components/context-menu/menu-option.svelte';
|
||||
import ThumbnailSelection from './thumbnail-selection.svelte';
|
||||
import ControlAppBar from '../shared-components/control-app-bar.svelte';
|
||||
|
||||
export let album: AlbumResponseDto;
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
|
||||
$: {
|
||||
if (album.assets?.length < 6) {
|
||||
thumbnailSize = Math.floor(viewWidth / album.assets.length - album.assets.length);
|
||||
thumbnailSize = Math.floor(viewWidth / album.assetCount - album.assetCount);
|
||||
} else {
|
||||
thumbnailSize = Math.floor(viewWidth / 6 - 6);
|
||||
}
|
||||
@@ -69,7 +69,7 @@
|
||||
|
||||
const getDateRange = () => {
|
||||
const startDate = new Date(album.assets[0].createdAt);
|
||||
const endDate = new Date(album.assets[album.assets.length - 1].createdAt);
|
||||
const endDate = new Date(album.assets[album.assetCount - 1].createdAt);
|
||||
|
||||
const timeFormatOption: Intl.DateTimeFormatOptions = {
|
||||
month: 'short',
|
||||
@@ -135,7 +135,7 @@
|
||||
};
|
||||
const navigateAssetForward = () => {
|
||||
try {
|
||||
if (currentViewAssetIndex < album.assets.length - 1) {
|
||||
if (currentViewAssetIndex < album.assetCount - 1) {
|
||||
currentViewAssetIndex++;
|
||||
selectedAsset = album.assets[currentViewAssetIndex];
|
||||
pushState(selectedAsset.id);
|
||||
@@ -272,7 +272,7 @@
|
||||
<section class="bg-immich-bg">
|
||||
<!-- Multiselection mode app bar -->
|
||||
{#if isMultiSelectionMode}
|
||||
<AlbumAppBar
|
||||
<ControlAppBar
|
||||
on:close-button-click={clearMultiSelectAssetAssetHandler}
|
||||
backIcon={Close}
|
||||
tailwindClasses={'bg-white shadow-md'}
|
||||
@@ -289,14 +289,14 @@
|
||||
/>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
</AlbumAppBar>
|
||||
</ControlAppBar>
|
||||
{/if}
|
||||
|
||||
<!-- Default app bar -->
|
||||
{#if !isMultiSelectionMode}
|
||||
<AlbumAppBar on:close-button-click={() => goto(backUrl)} backIcon={ArrowLeft}>
|
||||
<ControlAppBar on:close-button-click={() => goto(backUrl)} backIcon={ArrowLeft}>
|
||||
<svelte:fragment slot="trailing">
|
||||
{#if album.assets.length > 0}
|
||||
{#if album.assetCount > 0}
|
||||
<CircleIconButton
|
||||
title="Add Photos"
|
||||
on:click={() => (isShowAssetSelection = true)}
|
||||
@@ -322,14 +322,14 @@
|
||||
|
||||
{#if isCreatingSharedAlbum && album.sharedUsers.length == 0}
|
||||
<button
|
||||
disabled={album.assets.length == 0}
|
||||
disabled={album.assetCount == 0}
|
||||
on:click={() => (isShowShareUserSelection = true)}
|
||||
class="immich-text-button border bg-immich-primary text-gray-50 hover:bg-immich-primary/75 px-6 text-sm disabled:opacity-25 disabled:bg-gray-500 disabled:cursor-not-allowed"
|
||||
><span class="px-2">Share</span></button
|
||||
>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
</AlbumAppBar>
|
||||
</ControlAppBar>
|
||||
{/if}
|
||||
|
||||
<section class="m-auto my-[160px] w-[60%]">
|
||||
@@ -351,7 +351,7 @@
|
||||
bind:this={titleInput}
|
||||
/>
|
||||
|
||||
{#if album.assets.length > 0}
|
||||
{#if album.assetCount > 0}
|
||||
<p class="my-4 text-sm text-gray-500 font-medium">{getDateRange()}</p>
|
||||
{/if}
|
||||
|
||||
@@ -375,11 +375,11 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if album.assets.length > 0}
|
||||
{#if album.assetCount > 0}
|
||||
<div class="flex flex-wrap gap-1 w-full pb-20" bind:clientWidth={viewWidth}>
|
||||
{#each album.assets as asset}
|
||||
{#key asset.id}
|
||||
{#if album.assets.length < 7}
|
||||
{#if album.assetCount < 7}
|
||||
<ImmichThumbnail
|
||||
{asset}
|
||||
{thumbnailSize}
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
import moment from 'moment';
|
||||
import ImmichThumbnail from '../shared-components/immich-thumbnail.svelte';
|
||||
import { AssetResponseDto } from '@api';
|
||||
import AlbumAppBar from './album-app-bar.svelte';
|
||||
import { openFileUploadDialog, UploadType } from '$lib/utils/file-uploader';
|
||||
import { albumUploadAssetStore } from '$lib/stores/album-upload-asset';
|
||||
import ControlAppBar from '../shared-components/control-app-bar.svelte';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
@@ -172,7 +172,7 @@
|
||||
transition:fly={{ y: 500, duration: 100, easing: quintOut }}
|
||||
class="absolute top-0 left-0 w-full h-full py-[160px] bg-immich-bg z-[9999]"
|
||||
>
|
||||
<AlbumAppBar on:close-button-click={() => dispatch('go-back')}>
|
||||
<ControlAppBar on:close-button-click={() => dispatch('go-back')}>
|
||||
<svelte:fragment slot="leading">
|
||||
{#if selectedAsset.size == 0}
|
||||
<p class="text-lg">Add to album</p>
|
||||
@@ -195,7 +195,7 @@
|
||||
><span class="px-2">Done</span></button
|
||||
>
|
||||
</svelte:fragment>
|
||||
</AlbumAppBar>
|
||||
</ControlAppBar>
|
||||
|
||||
<section class="flex flex-wrap gap-14 px-20 overflow-y-auto">
|
||||
{#each $assetsGroupByDate as assetsInDateGroup, groupIndex}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
import { fly } from 'svelte/transition';
|
||||
import ControlAppBar from '../shared-components/control-app-bar.svelte';
|
||||
import ImmichThumbnail from '../shared-components/immich-thumbnail.svelte';
|
||||
import AlbumAppBar from './album-app-bar.svelte';
|
||||
|
||||
export let album: AlbumResponseDto;
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
transition:fly={{ y: 500, duration: 100, easing: quintOut }}
|
||||
class="absolute top-0 left-0 w-full h-full py-[160px] bg-immich-bg z-[9999]"
|
||||
>
|
||||
<AlbumAppBar on:close-button-click={() => dispatch('close')}>
|
||||
<ControlAppBar on:close-button-click={() => dispatch('close')}>
|
||||
<svelte:fragment slot="leading">
|
||||
<p class="text-lg">Select album cover</p>
|
||||
</svelte:fragment>
|
||||
@@ -37,7 +37,7 @@
|
||||
><span class="px-2">Done</span></button
|
||||
>
|
||||
</svelte:fragment>
|
||||
</AlbumAppBar>
|
||||
</ControlAppBar>
|
||||
|
||||
<section class="flex flex-wrap gap-14 px-20 overflow-y-auto">
|
||||
<!-- Image grid -->
|
||||
|
||||
@@ -1,122 +1,122 @@
|
||||
<script lang="ts">
|
||||
import { api } from '@api';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { api } from '@api';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
let error: string;
|
||||
let success: string;
|
||||
let error: string;
|
||||
let success: string;
|
||||
|
||||
let password: string = '';
|
||||
let confirmPassowrd: string = '';
|
||||
let password = '';
|
||||
let confirmPassowrd = '';
|
||||
|
||||
let canCreateUser = false;
|
||||
let canCreateUser = false;
|
||||
|
||||
$: {
|
||||
if (password !== confirmPassowrd && confirmPassowrd.length > 0) {
|
||||
error = 'Password does not match';
|
||||
canCreateUser = false;
|
||||
} else {
|
||||
error = '';
|
||||
canCreateUser = true;
|
||||
}
|
||||
}
|
||||
const dispatch = createEventDispatcher();
|
||||
$: {
|
||||
if (password !== confirmPassowrd && confirmPassowrd.length > 0) {
|
||||
error = 'Password does not match';
|
||||
canCreateUser = false;
|
||||
} else {
|
||||
error = '';
|
||||
canCreateUser = true;
|
||||
}
|
||||
}
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
async function registerUser(event: SubmitEvent) {
|
||||
console.log('registerUser');
|
||||
if (canCreateUser) {
|
||||
error = '';
|
||||
async function registerUser(event: SubmitEvent) {
|
||||
if (canCreateUser) {
|
||||
error = '';
|
||||
|
||||
const formElement = event.target as HTMLFormElement;
|
||||
const formElement = event.target as HTMLFormElement;
|
||||
|
||||
const form = new FormData(formElement);
|
||||
const form = new FormData(formElement);
|
||||
|
||||
const email = form.get('email');
|
||||
const password = form.get('password');
|
||||
const firstName = form.get('firstName');
|
||||
const lastName = form.get('lastName');
|
||||
const email = form.get('email');
|
||||
const password = form.get('password');
|
||||
const firstName = form.get('firstName');
|
||||
const lastName = form.get('lastName');
|
||||
|
||||
const { status } = await api.userApi.createUser({
|
||||
email: String(email),
|
||||
password: String(password),
|
||||
firstName: String(firstName),
|
||||
lastName: String(lastName)
|
||||
});
|
||||
const {status} = await api.userApi.createUser({
|
||||
email: String(email),
|
||||
password: String(password),
|
||||
firstName: String(firstName),
|
||||
lastName: String(lastName)
|
||||
});
|
||||
|
||||
if (status === 201) {
|
||||
success = 'New user created';
|
||||
if (status === 201) {
|
||||
success = 'New user created';
|
||||
|
||||
dispatch('user-created');
|
||||
return;
|
||||
} else {
|
||||
error = 'Error create user account';
|
||||
}
|
||||
}
|
||||
}
|
||||
dispatch('user-created');
|
||||
return;
|
||||
} else {
|
||||
error = 'Error create user account';
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="border bg-white p-4 shadow-sm w-[500px] rounded-md py-8">
|
||||
<div class="flex flex-col place-items-center place-content-center gap-4 px-4">
|
||||
<img class="text-center" src="/immich-logo.svg" height="100" width="100" alt="immich-logo" />
|
||||
<h1 class="text-2xl text-immich-primary font-medium">Create new user</h1>
|
||||
<p class="text-sm border rounded-md p-4 font-mono text-gray-600">
|
||||
Please provide your user with the password, they will have to change it on their first sign
|
||||
in.
|
||||
</p>
|
||||
</div>
|
||||
<div class="border bg-white p-4 shadow-sm w-[500px] rounded-3xl py-8">
|
||||
<div class="flex flex-col place-items-center place-content-center gap-4 px-4">
|
||||
<img class="text-center" src="/immich-logo.svg" height="100" width="100" alt="immich-logo"/>
|
||||
<h1 class="text-2xl text-immich-primary font-medium">Create new user</h1>
|
||||
<p class="text-sm border rounded-md p-4 font-mono text-gray-600">
|
||||
Please provide your user with the password, they will have to change it on their first sign
|
||||
in.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form on:submit|preventDefault={registerUser} autocomplete="off">
|
||||
<div class="m-4 flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="email">Email</label>
|
||||
<input class="immich-form-input" id="email" name="email" type="email" required />
|
||||
</div>
|
||||
<form on:submit|preventDefault={registerUser} autocomplete="off">
|
||||
<div class="m-4 flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="email">Email</label>
|
||||
<input class="immich-form-input" id="email" name="email" type="email" required/>
|
||||
</div>
|
||||
|
||||
<div class="m-4 flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="password">Password</label>
|
||||
<input
|
||||
class="immich-form-input"
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
bind:value={password}
|
||||
/>
|
||||
</div>
|
||||
<div class="m-4 flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="password">Password</label>
|
||||
<input
|
||||
class="immich-form-input"
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
bind:value={password}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="m-4 flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="confirmPassword">Confirm Password</label>
|
||||
<input
|
||||
class="immich-form-input"
|
||||
id="confirmPassword"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
bind:value={confirmPassowrd}
|
||||
/>
|
||||
</div>
|
||||
<div class="m-4 flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="confirmPassword">Confirm Password</label>
|
||||
<input
|
||||
class="immich-form-input"
|
||||
id="confirmPassword"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
bind:value={confirmPassowrd}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="m-4 flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="firstName">First Name</label>
|
||||
<input class="immich-form-input" id="firstName" name="firstName" type="text" required />
|
||||
</div>
|
||||
<div class="m-4 flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="firstName">First Name</label>
|
||||
<input class="immich-form-input" id="firstName" name="firstName" type="text" required/>
|
||||
</div>
|
||||
|
||||
<div class="m-4 flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="lastName">Last Name</label>
|
||||
<input class="immich-form-input" id="lastName" name="lastName" type="text" required />
|
||||
</div>
|
||||
<div class="m-4 flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="lastName">Last Name</label>
|
||||
<input class="immich-form-input" id="lastName" name="lastName" type="text" required/>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="text-red-400 ml-4 text-sm">{error}</p>
|
||||
{/if}
|
||||
{#if error}
|
||||
<p class="text-red-400 ml-4 text-sm">{error}</p>
|
||||
{/if}
|
||||
|
||||
{#if success}
|
||||
<p class="text-immich-primary ml-4 text-sm">{success}</p>
|
||||
{/if}
|
||||
<div class="flex w-full">
|
||||
<button
|
||||
type="submit"
|
||||
class="m-4 p-2 bg-immich-primary hover:bg-immich-primary/75 px-6 py-4 text-white rounded-md shadow-md w-full"
|
||||
>Create</button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
{#if success}
|
||||
<p class="text-immich-primary ml-4 text-sm">{success}</p>
|
||||
{/if}
|
||||
<div class="flex w-full">
|
||||
<button
|
||||
type="submit"
|
||||
class="m-4 bg-immich-primary hover:bg-immich-primary/75 px-6 py-3 text-white rounded-full shadow-md w-full font-medium"
|
||||
>Create
|
||||
</button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
103
web/src/lib/components/forms/edit-user-form.svelte
Normal file
103
web/src/lib/components/forms/edit-user-form.svelte
Normal file
@@ -0,0 +1,103 @@
|
||||
<script lang="ts">
|
||||
import { api, UserResponseDto } from '@api';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import AccountEditOutline from 'svelte-material-icons/AccountEditOutline.svelte';
|
||||
|
||||
export let user: UserResponseDto;
|
||||
|
||||
let error: string;
|
||||
let success: string;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
const editUser = async (event: SubmitEvent) => {
|
||||
|
||||
const formElement = event.target as HTMLFormElement;
|
||||
const form = new FormData(formElement);
|
||||
|
||||
const firstName = form.get('firstName');
|
||||
const lastName = form.get('lastName');
|
||||
|
||||
|
||||
const {status} = await api.userApi.updateUser({
|
||||
id: user.id,
|
||||
firstName: firstName.toString(),
|
||||
lastName: lastName.toString()
|
||||
}).catch(e => console.log("Error updating user ", e));
|
||||
|
||||
if (status == 200) {
|
||||
dispatch('edit-success');
|
||||
}
|
||||
}
|
||||
|
||||
const resetPassword = async () => {
|
||||
const defaultPassword = 'password'
|
||||
|
||||
const {status} = await api.userApi.updateUser({
|
||||
id: user.id,
|
||||
password: defaultPassword,
|
||||
shouldChangePassword: true,
|
||||
|
||||
}).catch(e => console.log("Error updating user ", e));
|
||||
|
||||
if (status == 200) {
|
||||
dispatch('reset-password-success');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="border bg-white p-4 shadow-sm w-[500px] rounded-3xl py-8">
|
||||
<div class="flex flex-col place-items-center place-content-center gap-4 px-4">
|
||||
<!-- <img class="text-center" src="/immich-logo.svg" height="100" width="100" alt="immich-logo"/>-->
|
||||
<AccountEditOutline size="4em" color="#4250affe"/>
|
||||
<h1 class="text-2xl text-immich-primary font-medium">Edit user</h1>
|
||||
</div>
|
||||
|
||||
<form on:submit|preventDefault={editUser} autocomplete="off">
|
||||
<div class="m-4 flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="email">Email
|
||||
(cannot change)</label>
|
||||
<input class="immich-form-input disabled:bg-gray-200 hover:cursor-not-allowed"
|
||||
id="email" name="email"
|
||||
type="email" disabled
|
||||
bind:value={user.email}/>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="m-4 flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="firstName">First Name</label>
|
||||
<input class="immich-form-input" id="firstName" name="firstName" type="text" required
|
||||
bind:value={user.firstName}/>
|
||||
</div>
|
||||
|
||||
<div class="m-4 flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="lastName">Last Name</label>
|
||||
<input class="immich-form-input" id="lastName" name="lastName" type="text" required
|
||||
bind:value={user.lastName}/>
|
||||
</div>
|
||||
|
||||
|
||||
{#if error}
|
||||
<p class="text-red-400 ml-4 text-sm">{error}</p>
|
||||
{/if}
|
||||
|
||||
{#if success}
|
||||
<p class="text-immich-primary ml-4 text-sm">{success}</p>
|
||||
{/if}
|
||||
<div class="flex w-full px-4 gap-4 mt-8">
|
||||
<button on:click={resetPassword}
|
||||
class="flex-1 transition-colors bg-[#F9DEDC] hover:bg-red-50 text-[#410E0B] px-6 py-3 rounded-full w-full font-medium"
|
||||
|
||||
>Reset password
|
||||
</button
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
class="flex-1 transition-colors bg-immich-primary hover:bg-immich-primary/75 px-6 py-3 text-white rounded-full shadow-md w-full font-medium"
|
||||
>Confirm
|
||||
</button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -1,114 +1,181 @@
|
||||
<script context="module" lang="ts">
|
||||
import type { Load } from '@sveltejs/kit';
|
||||
import { api, UserResponseDto } from '@api';
|
||||
import type { Load } from '@sveltejs/kit';
|
||||
import { api, UserResponseDto } from '@api';
|
||||
import { browser } from '$app/env';
|
||||
|
||||
export const load: Load = async ({ fetch, session }) => {
|
||||
if (!browser && !session.user) {
|
||||
return {
|
||||
status: 302,
|
||||
redirect: '/auth/login'
|
||||
};
|
||||
}
|
||||
export const load: Load = async ({fetch, session}) => {
|
||||
if (!browser && !session.user) {
|
||||
return {
|
||||
status: 302,
|
||||
redirect: '/auth/login'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const [user, allUsers] = await Promise.all([
|
||||
fetch('/data/user/get-my-user-info').then((r) => r.json()),
|
||||
fetch('/data/user/get-all-users?isAll=false').then((r) => r.json())
|
||||
]);
|
||||
try {
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
props: {
|
||||
user: user,
|
||||
allUsers: allUsers
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
status: 302,
|
||||
redirect: '/auth/login'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const user: UserResponseDto = await fetch('/data/user/get-my-user-info').then((r) => r.json());
|
||||
const allUsers: UserResponseDto[] = await fetch<UserResponseDto[]>('/data/user/get-all-users?isAll=false').then((r) => r.json());
|
||||
|
||||
if (!user.isAdmin) {
|
||||
return {
|
||||
status: 302,
|
||||
redirect: '/photos'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
props: {
|
||||
user: user,
|
||||
allUsers: allUsers
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
status: 302,
|
||||
redirect: '/auth/login'
|
||||
};
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
import type { ImmichUser } from '$lib/models/immich-user';
|
||||
import { AdminSideBarSelection } from '$lib/models/admin-sidebar-selection';
|
||||
import SideBarButton from '$lib/components/shared-components/side-bar/side-bar-button.svelte';
|
||||
import AccountMultipleOutline from 'svelte-material-icons/AccountMultipleOutline.svelte';
|
||||
import NavigationBar from '$lib/components/shared-components/navigation-bar.svelte';
|
||||
import UserManagement from '$lib/components/admin-page/user-management.svelte';
|
||||
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
|
||||
import CreateUserForm from '$lib/components/forms/create-user-form.svelte';
|
||||
import StatusBox from '$lib/components/shared-components/status-box.svelte';
|
||||
import { browser } from '$app/env';
|
||||
import { AdminSideBarSelection } from '$lib/models/admin-sidebar-selection';
|
||||
import SideBarButton from '$lib/components/shared-components/side-bar/side-bar-button.svelte';
|
||||
import AccountMultipleOutline from 'svelte-material-icons/AccountMultipleOutline.svelte';
|
||||
import NavigationBar from '$lib/components/shared-components/navigation-bar.svelte';
|
||||
import UserManagement from '$lib/components/admin-page/user-management.svelte';
|
||||
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
|
||||
import CreateUserForm from '$lib/components/forms/create-user-form.svelte';
|
||||
import EditUserForm from '$lib/components/forms/edit-user-form.svelte';
|
||||
import StatusBox from '$lib/components/shared-components/status-box.svelte';
|
||||
|
||||
let selectedAction: AdminSideBarSelection = AdminSideBarSelection.USER_MANAGEMENT;
|
||||
|
||||
export let user: ImmichUser;
|
||||
export let allUsers: UserResponseDto[];
|
||||
let selectedAction: AdminSideBarSelection = AdminSideBarSelection.USER_MANAGEMENT;
|
||||
|
||||
let shouldShowCreateUserForm: boolean;
|
||||
export let user: UserResponseDto;
|
||||
export let allUsers: UserResponseDto[];
|
||||
|
||||
const onButtonClicked = (buttonType: CustomEvent) => {
|
||||
selectedAction = buttonType.detail['actionType'] as AdminSideBarSelection;
|
||||
};
|
||||
let editUser: UserResponseDto;
|
||||
|
||||
onMount(() => {
|
||||
selectedAction = AdminSideBarSelection.USER_MANAGEMENT;
|
||||
});
|
||||
let shouldShowEditUserForm = false;
|
||||
let shouldShowCreateUserForm = false;
|
||||
let shouldShowInfoPanel = false;
|
||||
|
||||
const onUserCreated = async () => {
|
||||
const { data } = await api.userApi.getAllUsers(false);
|
||||
allUsers = data;
|
||||
const onButtonClicked = (buttonType: CustomEvent) => {
|
||||
selectedAction = buttonType.detail['actionType'] as AdminSideBarSelection;
|
||||
};
|
||||
|
||||
shouldShowCreateUserForm = false;
|
||||
};
|
||||
onMount(() => {
|
||||
selectedAction = AdminSideBarSelection.USER_MANAGEMENT;
|
||||
});
|
||||
|
||||
const onUserCreated = async () => {
|
||||
const {data} = await api.userApi.getAllUsers(false);
|
||||
allUsers = data;
|
||||
shouldShowCreateUserForm = false;
|
||||
};
|
||||
|
||||
const editUserHandler = async (event: CustomEvent) => {
|
||||
const {user} = event.detail;
|
||||
editUser = user;
|
||||
shouldShowEditUserForm = true;
|
||||
};
|
||||
|
||||
const onEditUserSuccess = async () => {
|
||||
const {data} = await api.userApi.getAllUsers(false);
|
||||
allUsers = data;
|
||||
shouldShowEditUserForm = false;
|
||||
}
|
||||
|
||||
const onEditPasswordSuccess = async () => {
|
||||
const {data} = await api.userApi.getAllUsers(false);
|
||||
allUsers = data;
|
||||
shouldShowEditUserForm = false;
|
||||
shouldShowInfoPanel = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Administration - Immich</title>
|
||||
<title>Administration - Immich</title>
|
||||
</svelte:head>
|
||||
|
||||
<NavigationBar {user} />
|
||||
<NavigationBar {user}/>
|
||||
|
||||
{#if shouldShowCreateUserForm}
|
||||
<FullScreenModal on:clickOutside={() => (shouldShowCreateUserForm = false)}>
|
||||
<div>
|
||||
<CreateUserForm on:user-created={onUserCreated} />
|
||||
</div>
|
||||
</FullScreenModal>
|
||||
<FullScreenModal on:clickOutside={() => (shouldShowCreateUserForm = false)}>
|
||||
<CreateUserForm on:user-created={onUserCreated}/>
|
||||
</FullScreenModal>
|
||||
{/if}
|
||||
|
||||
{#if shouldShowEditUserForm}
|
||||
<FullScreenModal on:clickOutside={() => (shouldShowEditUserForm = false)}>
|
||||
<EditUserForm user={editUser} on:edit-success={onEditUserSuccess}
|
||||
on:reset-password-success={onEditPasswordSuccess}/>
|
||||
</FullScreenModal>
|
||||
{/if}
|
||||
|
||||
{#if shouldShowInfoPanel}
|
||||
<FullScreenModal on:clickOutside={() => (shouldShowInfoPanel = false)}>
|
||||
|
||||
<div class="border bg-white shadow-sm w-[500px] rounded-3xl p-8 text-sm">
|
||||
<h1 class="font-bold text-immich-primary text-lg mb-4">Password reset success</h1>
|
||||
|
||||
<p>
|
||||
The user's password has been reset to the default <code
|
||||
class="font-bold bg-gray-200 px-2 py-1 rounded-md text-immich-primary">password</code>
|
||||
<br>
|
||||
Please inform the user, and they will need to change the password at the next log-on.
|
||||
</p>
|
||||
|
||||
<div class="flex w-full">
|
||||
<button
|
||||
on:click={() => shouldShowInfoPanel = false}
|
||||
class="mt-6 bg-immich-primary hover:bg-immich-primary/75 px-6 py-3 text-white rounded-full shadow-md w-full font-medium"
|
||||
>Done
|
||||
</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</FullScreenModal>
|
||||
{/if}
|
||||
|
||||
|
||||
<section class="grid grid-cols-[250px_auto] relative pt-[72px] h-screen">
|
||||
<section id="admin-sidebar" class="pt-8 pr-6 flex flex-col">
|
||||
<SideBarButton
|
||||
title="User"
|
||||
logo={AccountMultipleOutline}
|
||||
actionType={AdminSideBarSelection.USER_MANAGEMENT}
|
||||
isSelected={selectedAction === AdminSideBarSelection.USER_MANAGEMENT}
|
||||
on:selected={onButtonClicked}
|
||||
/>
|
||||
<section id="admin-sidebar" class="pt-8 pr-6 flex flex-col">
|
||||
<SideBarButton
|
||||
title="User"
|
||||
logo={AccountMultipleOutline}
|
||||
actionType={AdminSideBarSelection.USER_MANAGEMENT}
|
||||
isSelected={selectedAction === AdminSideBarSelection.USER_MANAGEMENT}
|
||||
on:selected={onButtonClicked}
|
||||
/>
|
||||
|
||||
<div class="mb-6 mt-auto">
|
||||
<StatusBox />
|
||||
</div>
|
||||
</section>
|
||||
<section class="overflow-y-auto relative">
|
||||
<div id="setting-title" class="pt-10 fixed w-full z-50 bg-immich-bg">
|
||||
<h1 class="text-lg ml-8 mb-4 text-immich-primary font-medium">{selectedAction}</h1>
|
||||
<hr />
|
||||
</div>
|
||||
<div class="mb-6 mt-auto">
|
||||
<StatusBox/>
|
||||
</div>
|
||||
</section>
|
||||
<section class="overflow-y-auto relative">
|
||||
<div id="setting-title" class="pt-10 fixed w-full z-50 bg-immich-bg">
|
||||
<h1 class="text-lg ml-8 mb-4 text-immich-primary font-medium">{selectedAction}</h1>
|
||||
<hr/>
|
||||
</div>
|
||||
|
||||
|
||||
<section id="setting-content" class="relative pt-[85px] flex place-content-center">
|
||||
<section class="w-[800px] pt-4">
|
||||
{#if selectedAction === AdminSideBarSelection.USER_MANAGEMENT}
|
||||
<UserManagement
|
||||
{allUsers}
|
||||
on:create-user={() => (shouldShowCreateUserForm = true)}
|
||||
on:edit-user={editUserHandler}
|
||||
/>
|
||||
{/if}
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section id="setting-content" class="relative pt-[85px] flex place-content-center">
|
||||
<section class="w-[800px] pt-4">
|
||||
{#if selectedAction === AdminSideBarSelection.USER_MANAGEMENT}
|
||||
<UserManagement {allUsers} on:createUser={() => (shouldShowCreateUserForm = true)} />
|
||||
{/if}
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
|
||||
// Delete album that has no photos and is named 'Untitled'
|
||||
for (const album of albums) {
|
||||
if (album.albumName === 'Untitled' && album.assets.length === 0) {
|
||||
if (album.albumName === 'Untitled' && album.assetCount === 0) {
|
||||
const isDeleted = await autoDeleteAlbum(album);
|
||||
|
||||
if (isDeleted) {
|
||||
|
||||
@@ -1,56 +1,58 @@
|
||||
<script context="module" lang="ts">
|
||||
export const prerender = false;
|
||||
import type { Load } from '@sveltejs/kit';
|
||||
import { api } from '@api';
|
||||
export const prerender = false;
|
||||
import type { Load } from '@sveltejs/kit';
|
||||
import { api } from '@api';
|
||||
import { browser } from '$app/env';
|
||||
|
||||
export const load: Load = async () => {
|
||||
if (browser) {
|
||||
try {
|
||||
const { data: user } = await api.userApi.getMyUserInfo();
|
||||
export const load: Load = async () => {
|
||||
if (browser) {
|
||||
try {
|
||||
const {data: user} = await api.userApi.getMyUserInfo();
|
||||
|
||||
return {
|
||||
status: 302,
|
||||
redirect: '/photos'
|
||||
};
|
||||
} catch (e) {}
|
||||
return {
|
||||
status: 302,
|
||||
redirect: '/photos'
|
||||
};
|
||||
} catch (e) {
|
||||
}
|
||||
|
||||
const { data } = await api.userApi.getUserCount();
|
||||
const {data} = await api.userApi.getUserCount();
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
props: {
|
||||
isAdminUserExist: data.userCount == 0 ? false : true
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
return {
|
||||
status: 200,
|
||||
props: {
|
||||
isAdminUserExist: data.userCount != 0
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { browser } from '$app/env';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
export let isAdminUserExist: boolean;
|
||||
export let isAdminUserExist: boolean;
|
||||
|
||||
async function onGettingStartedClicked() {
|
||||
isAdminUserExist ? goto('/auth/login') : goto('/auth/register');
|
||||
}
|
||||
async function onGettingStartedClicked() {
|
||||
isAdminUserExist ? await goto('/auth/login') : await goto('/auth/register');
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Welcome 🎉 - Immich</title>
|
||||
<meta name="description" content="Immich Web Interface" />
|
||||
<title>Welcome 🎉 - Immich</title>
|
||||
<meta name="description" content="Immich Web Interface"/>
|
||||
</svelte:head>
|
||||
|
||||
<section class="h-screen w-screen flex place-items-center place-content-center">
|
||||
<div class="flex flex-col place-items-center gap-8 text-center max-w-[350px]">
|
||||
<div class="flex place-items-center place-content-center ">
|
||||
<img class="text-center" src="immich-logo.svg" height="200" width="200" alt="immich-logo" />
|
||||
</div>
|
||||
<h1 class="text-4xl text-immich-primary font-bold font-immich-title">Welcome to IMMICH Web</h1>
|
||||
<button
|
||||
class="border px-4 py-2 rounded-md bg-immich-primary hover:bg-immich-primary/75 text-white font-bold w-[200px]"
|
||||
on:click={onGettingStartedClicked}>Getting Started</button
|
||||
>
|
||||
</div>
|
||||
<div class="flex flex-col place-items-center gap-8 text-center max-w-[350px]">
|
||||
<div class="flex place-items-center place-content-center ">
|
||||
<img class="text-center" src="immich-logo.svg" height="200" width="200" alt="immich-logo"/>
|
||||
</div>
|
||||
<h1 class="text-4xl text-immich-primary font-bold font-immich-title">Welcome to IMMICH Web</h1>
|
||||
<button
|
||||
class="border px-4 py-2 rounded-md bg-immich-primary hover:bg-immich-primary/75 text-white font-bold w-[200px]"
|
||||
on:click={onGettingStartedClicked}>Getting Started
|
||||
</button
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import type { Load } from '@sveltejs/kit';
|
||||
import { setAssetInfo } from '$lib/stores/assets';
|
||||
|
||||
export const load: Load = async ({ fetch, session }) => {
|
||||
if (!browser && !session.user) {
|
||||
return {
|
||||
@@ -39,20 +40,31 @@
|
||||
import NavigationBar from '$lib/components/shared-components/navigation-bar.svelte';
|
||||
import CheckCircle from 'svelte-material-icons/CheckCircle.svelte';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { assetsGroupByDate, flattenAssetGroupByDate } from '$lib/stores/assets';
|
||||
import { assetsGroupByDate, flattenAssetGroupByDate, assets } from '$lib/stores/assets';
|
||||
import ImmichThumbnail from '$lib/components/shared-components/immich-thumbnail.svelte';
|
||||
import moment from 'moment';
|
||||
import AssetViewer from '$lib/components/asset-viewer/asset-viewer.svelte';
|
||||
import { openFileUploadDialog, UploadType } from '$lib/utils/file-uploader';
|
||||
import { AssetResponseDto, UserResponseDto } from '@api';
|
||||
import { api, AssetResponseDto, UserResponseDto } from '@api';
|
||||
import SideBar from '$lib/components/shared-components/side-bar/side-bar.svelte';
|
||||
import CircleOutline from 'svelte-material-icons/CircleOutline.svelte';
|
||||
import CircleIconButton from '$lib/components/shared-components/circle-icon-button.svelte';
|
||||
import DeleteOutline from 'svelte-material-icons/DeleteOutline.svelte';
|
||||
import Close from 'svelte-material-icons/Close.svelte';
|
||||
import { browser } from '$app/env';
|
||||
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
|
||||
|
||||
export let user: UserResponseDto;
|
||||
|
||||
let selectedGroupThumbnail: number | null;
|
||||
let isMouseOverGroup: boolean;
|
||||
|
||||
let multiSelectedAssets = new Set<AssetResponseDto>();
|
||||
$: isMultiSelectionMode = multiSelectedAssets.size > 0;
|
||||
|
||||
let selectedGroup: Set<number> = new Set();
|
||||
let existingGroup: Set<number> = new Set();
|
||||
|
||||
$: if (isMouseOverGroup == false) {
|
||||
selectedGroupThumbnail = null;
|
||||
}
|
||||
@@ -110,6 +122,91 @@
|
||||
isShowAssetViewer = false;
|
||||
history.pushState(null, '', `/photos`);
|
||||
};
|
||||
|
||||
const selectAssetHandler = (asset: AssetResponseDto, groupIndex: number) => {
|
||||
let temp = new Set(multiSelectedAssets);
|
||||
|
||||
if (multiSelectedAssets.has(asset)) {
|
||||
temp.delete(asset);
|
||||
|
||||
const tempSelectedGroup = new Set(selectedGroup);
|
||||
tempSelectedGroup.delete(groupIndex);
|
||||
selectedGroup = tempSelectedGroup;
|
||||
} else {
|
||||
temp.add(asset);
|
||||
}
|
||||
|
||||
multiSelectedAssets = temp;
|
||||
|
||||
// Check if all assets are selected in a group to toggle the group selection's icon
|
||||
if (!selectedGroup.has(groupIndex)) {
|
||||
const assetsInGroup = $assetsGroupByDate[groupIndex];
|
||||
let selectedAssetsInGroupCount = 0;
|
||||
|
||||
assetsInGroup.forEach((asset) => {
|
||||
if (multiSelectedAssets.has(asset)) {
|
||||
selectedAssetsInGroupCount++;
|
||||
}
|
||||
});
|
||||
|
||||
// if all assets are selected in a group, add the group to selected group
|
||||
if (selectedAssetsInGroupCount == assetsInGroup.length) {
|
||||
selectedGroup = selectedGroup.add(groupIndex);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const clearMultiSelectAssetAssetHandler = () => {
|
||||
multiSelectedAssets = new Set();
|
||||
selectedGroup = new Set();
|
||||
existingGroup = new Set();
|
||||
};
|
||||
|
||||
const selectAssetGroupHandler = (groupIndex: number) => {
|
||||
if (existingGroup.has(groupIndex)) return;
|
||||
|
||||
let tempSelectedGroup = new Set(selectedGroup);
|
||||
let tempSelectedAsset = new Set(multiSelectedAssets);
|
||||
|
||||
if (selectedGroup.has(groupIndex)) {
|
||||
tempSelectedGroup.delete(groupIndex);
|
||||
tempSelectedAsset.forEach((asset) => {
|
||||
if ($assetsGroupByDate[groupIndex].find((a) => a.id == asset.id)) {
|
||||
tempSelectedAsset.delete(asset);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
tempSelectedGroup.add(groupIndex);
|
||||
tempSelectedAsset = new Set([...multiSelectedAssets, ...$assetsGroupByDate[groupIndex]]);
|
||||
}
|
||||
|
||||
multiSelectedAssets = tempSelectedAsset;
|
||||
selectedGroup = tempSelectedGroup;
|
||||
};
|
||||
|
||||
const deleteSelectedAssetHandler = async () => {
|
||||
try {
|
||||
if (
|
||||
window.confirm(
|
||||
`Caution! Are you sure you want to delete ${multiSelectedAssets.size} assets? This step also deletes assets in the album(s) to which they belong. You can not undo this action!`
|
||||
)
|
||||
) {
|
||||
const { data: deletedAssets } = await api.assetApi.deleteAsset({
|
||||
ids: Array.from(multiSelectedAssets).map((a) => a.id)
|
||||
});
|
||||
|
||||
for (const asset of deletedAssets) {
|
||||
if (asset.status == 'SUCCESS') {
|
||||
$assets = $assets.filter((a) => a.id !== asset.id);
|
||||
}
|
||||
}
|
||||
|
||||
clearMultiSelectAssetAssetHandler();
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Error deleteSelectedAssetHandler', e);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -117,7 +214,28 @@
|
||||
</svelte:head>
|
||||
|
||||
<section>
|
||||
<NavigationBar {user} on:uploadClicked={() => openFileUploadDialog(UploadType.GENERAL)} />
|
||||
{#if isMultiSelectionMode}
|
||||
<ControlAppBar
|
||||
on:close-button-click={clearMultiSelectAssetAssetHandler}
|
||||
backIcon={Close}
|
||||
tailwindClasses={'bg-white shadow-md'}
|
||||
>
|
||||
<svelte:fragment slot="leading">
|
||||
<p class="font-medium text-immich-primary">Selected {multiSelectedAssets.size}</p>
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="trailing">
|
||||
<CircleIconButton
|
||||
title="Delete"
|
||||
logo={DeleteOutline}
|
||||
on:click={deleteSelectedAssetHandler}
|
||||
/>
|
||||
</svelte:fragment>
|
||||
</ControlAppBar>
|
||||
{/if}
|
||||
|
||||
{#if !isMultiSelectionMode}
|
||||
<NavigationBar {user} on:uploadClicked={() => openFileUploadDialog(UploadType.GENERAL)} />
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="grid grid-cols-[250px_auto] relative pt-[72px] h-screen bg-immich-bg">
|
||||
@@ -136,13 +254,20 @@
|
||||
>
|
||||
<!-- Date group title -->
|
||||
<p class="font-medium text-sm text-immich-fg mb-2 flex place-items-center h-6">
|
||||
{#if selectedGroupThumbnail === groupIndex && isMouseOverGroup}
|
||||
{#if (selectedGroupThumbnail === groupIndex && isMouseOverGroup) || selectedGroup.has(groupIndex)}
|
||||
<div
|
||||
in:fly={{ x: -24, duration: 200, opacity: 0.5 }}
|
||||
out:fly={{ x: -24, duration: 200 }}
|
||||
class="inline-block px-2 hover:cursor-pointer"
|
||||
on:click={() => selectAssetGroupHandler(groupIndex)}
|
||||
>
|
||||
<CheckCircle size="24" color="#757575" />
|
||||
{#if selectedGroup.has(groupIndex)}
|
||||
<CheckCircle size="24" color="#4250af" />
|
||||
{:else if existingGroup.has(groupIndex)}
|
||||
<CheckCircle size="24" color="#757575" />
|
||||
{:else}
|
||||
<CircleOutline size="24" color="#757575" />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -156,7 +281,12 @@
|
||||
<ImmichThumbnail
|
||||
{asset}
|
||||
on:mouseEvent={thumbnailMouseEventHandler}
|
||||
on:click={viewAssetHandler}
|
||||
on:click={(event) =>
|
||||
isMultiSelectionMode
|
||||
? selectAssetHandler(asset, groupIndex)
|
||||
: viewAssetHandler(event)}
|
||||
on:select={() => selectAssetHandler(asset, groupIndex)}
|
||||
selected={multiSelectedAssets.has(asset)}
|
||||
{groupIndex}
|
||||
/>
|
||||
{/key}
|
||||
|
||||
@@ -1,119 +1,120 @@
|
||||
<script context="module" lang="ts">
|
||||
export const prerender = false;
|
||||
export const prerender = false;
|
||||
|
||||
import type { Load } from '@sveltejs/kit';
|
||||
import { AlbumResponseDto, api, UserResponseDto } from '@api';
|
||||
import type { Load } from '@sveltejs/kit';
|
||||
import { AlbumResponseDto, api, UserResponseDto } from '@api';
|
||||
import { browser } from '$app/env';
|
||||
|
||||
export const load: Load = async ({ fetch, session }) => {
|
||||
if (!browser && !session.user) {
|
||||
return {
|
||||
status: 302,
|
||||
redirect: '/auth/login'
|
||||
};
|
||||
}
|
||||
export const load: Load = async ({fetch, session}) => {
|
||||
if (!browser && !session.user) {
|
||||
return {
|
||||
status: 302,
|
||||
redirect: '/auth/login'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const [user, sharedAlbums] = await Promise.all([
|
||||
fetch('/data/user/get-my-user-info').then((r) => r.json()),
|
||||
fetch('/data/album/get-all-albums?isShared=true').then((r) => r.json())
|
||||
]);
|
||||
try {
|
||||
const [user, sharedAlbums] = await Promise.all([
|
||||
fetch('/data/user/get-my-user-info').then((r) => r.json()),
|
||||
fetch('/data/album/get-all-albums?isShared=true').then((r) => r.json())
|
||||
]);
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
props: {
|
||||
user: user,
|
||||
sharedAlbums: sharedAlbums
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
status: 302,
|
||||
redirect: '/auth/login'
|
||||
};
|
||||
}
|
||||
};
|
||||
return {
|
||||
status: 200,
|
||||
props: {
|
||||
user: user,
|
||||
sharedAlbums: sharedAlbums
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
status: 302,
|
||||
redirect: '/auth/login'
|
||||
};
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import NavigationBar from '$lib/components/shared-components/navigation-bar.svelte';
|
||||
import SideBar from '$lib/components/shared-components/side-bar/side-bar.svelte';
|
||||
import PlusBoxOutline from 'svelte-material-icons/PlusBoxOutline.svelte';
|
||||
import SharedAlbumListTile from '$lib/components/sharing-page/shared-album-list-tile.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { browser } from '$app/env';
|
||||
import NavigationBar from '$lib/components/shared-components/navigation-bar.svelte';
|
||||
import SideBar from '$lib/components/shared-components/side-bar/side-bar.svelte';
|
||||
import PlusBoxOutline from 'svelte-material-icons/PlusBoxOutline.svelte';
|
||||
import SharedAlbumListTile from '$lib/components/sharing-page/shared-album-list-tile.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
export let user: UserResponseDto;
|
||||
export let sharedAlbums: AlbumResponseDto[];
|
||||
export let user: UserResponseDto;
|
||||
export let sharedAlbums: AlbumResponseDto[];
|
||||
|
||||
const createSharedAlbum = async () => {
|
||||
try {
|
||||
const { data: newAlbum } = await api.albumApi.createAlbum({
|
||||
albumName: 'Untitled'
|
||||
});
|
||||
const createSharedAlbum = async () => {
|
||||
try {
|
||||
const {data: newAlbum} = await api.albumApi.createAlbum({
|
||||
albumName: 'Untitled'
|
||||
});
|
||||
|
||||
goto('/albums/' + newAlbum.id);
|
||||
} catch (e) {
|
||||
console.log('Error [createAlbum] ', e);
|
||||
}
|
||||
};
|
||||
goto('/albums/' + newAlbum.id);
|
||||
} catch (e) {
|
||||
console.log('Error [createAlbum] ', e);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Albums - Immich</title>
|
||||
<title>Albums - Immich</title>
|
||||
</svelte:head>
|
||||
|
||||
<section>
|
||||
<NavigationBar {user} on:uploadClicked={() => {}} />
|
||||
<NavigationBar {user} on:uploadClicked={() => {}}/>
|
||||
</section>
|
||||
|
||||
<section class="grid grid-cols-[250px_auto] relative pt-[72px] h-screen bg-immich-bg">
|
||||
<SideBar />
|
||||
<SideBar/>
|
||||
|
||||
<section class="overflow-y-auto relative">
|
||||
<section id="album-content" class="relative pt-8 pl-4 mb-12 bg-immich-bg">
|
||||
<!-- Main Section -->
|
||||
<div class="px-4 flex justify-between place-items-center">
|
||||
<div>
|
||||
<p class="font-medium">Sharing</p>
|
||||
</div>
|
||||
<section class="overflow-y-auto relative">
|
||||
<section id="album-content" class="relative pt-8 pl-4 mb-12 bg-immich-bg">
|
||||
<!-- Main Section -->
|
||||
<div class="px-4 flex justify-between place-items-center">
|
||||
<div>
|
||||
<p class="font-medium">Sharing</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
on:click={createSharedAlbum}
|
||||
class="flex place-items-center gap-1 text-sm hover:bg-immich-primary/5 p-2 rounded-lg font-medium hover:text-gray-700"
|
||||
>
|
||||
<div>
|
||||
<button
|
||||
on:click={createSharedAlbum}
|
||||
class="flex place-items-center gap-1 text-sm hover:bg-immich-primary/5 p-2 rounded-lg font-medium hover:text-gray-700"
|
||||
>
|
||||
<span>
|
||||
<PlusBoxOutline size="18" />
|
||||
<PlusBoxOutline size="18"/>
|
||||
</span>
|
||||
<p>Create shared album</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p>Create shared album</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="my-4">
|
||||
<hr />
|
||||
</div>
|
||||
<div class="my-4">
|
||||
<hr/>
|
||||
</div>
|
||||
|
||||
<!-- Share Album List -->
|
||||
<div class="w-full flex flex-col place-items-center">
|
||||
{#each sharedAlbums as album}
|
||||
<a sveltekit:prefetch href={`albums/${album.id}`}>
|
||||
<SharedAlbumListTile {album} {user} /></a
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
<!-- Share Album List -->
|
||||
<div class="w-full flex flex-col place-items-center">
|
||||
{#each sharedAlbums as album}
|
||||
<a sveltekit:prefetch href={`albums/${album.id}`}>
|
||||
<SharedAlbumListTile {album} {user}/>
|
||||
</a
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Empty List -->
|
||||
{#if sharedAlbums.length === 0}
|
||||
<div
|
||||
class="border p-5 w-[50%] m-auto mt-10 bg-gray-50 rounded-3xl flex flex-col place-content-center place-items-center"
|
||||
>
|
||||
<img src="/empty-2.svg" alt="Empty shared album" width="500" />
|
||||
<p class="text-center text-immich-text-gray-500">
|
||||
Create a shared album to share photos and videos with people in your network
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</section>
|
||||
<!-- Empty List -->
|
||||
{#if sharedAlbums.length === 0}
|
||||
<div
|
||||
class="border p-5 w-[50%] m-auto mt-10 bg-gray-50 rounded-3xl flex flex-col place-content-center place-items-center"
|
||||
>
|
||||
<img src="/empty-2.svg" alt="Empty shared album" width="500"/>
|
||||
<p class="text-center text-immich-text-gray-500">
|
||||
Create a shared album to share photos and videos with people in your network
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user