mirror of
https://github.com/immich-app/immich.git
synced 2025-12-13 16:20:43 -08:00
Compare commits
21 Commits
workflow-u
...
revert-sve
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27fdf31646 | ||
|
|
4cbce072be | ||
|
|
5e5bb7e87d | ||
|
|
b052893a1e | ||
|
|
15e58595fd | ||
|
|
6d499c782a | ||
|
|
7af99b8606 | ||
|
|
01e39277e0 | ||
|
|
06e79703da | ||
|
|
c360781565 | ||
|
|
287f6d5c94 | ||
|
|
fe9125a3d1 | ||
|
|
8b31936bb6 | ||
|
|
19958dfd83 | ||
|
|
1e1cf0d1fe | ||
|
|
879e0ea131 | ||
|
|
42136f9091 | ||
|
|
1109c32891 | ||
|
|
3c80049192 | ||
|
|
8f1669efbe | ||
|
|
146bf65d02 |
8
.github/workflows/build-mobile.yml
vendored
8
.github/workflows/build-mobile.yml
vendored
@@ -222,6 +222,7 @@ jobs:
|
|||||||
uses: ruby/setup-ruby@v1
|
uses: ruby/setup-ruby@v1
|
||||||
with:
|
with:
|
||||||
ruby-version: '3.3'
|
ruby-version: '3.3'
|
||||||
|
bundler-cache: true
|
||||||
working-directory: ./mobile/ios
|
working-directory: ./mobile/ios
|
||||||
|
|
||||||
- name: Install CocoaPods dependencies
|
- name: Install CocoaPods dependencies
|
||||||
@@ -229,13 +230,6 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
pod install
|
pod install
|
||||||
|
|
||||||
- name: Install Fastlane
|
|
||||||
working-directory: ./mobile/ios
|
|
||||||
run: |
|
|
||||||
gem install bundler
|
|
||||||
bundle config set --local path 'vendor/bundle'
|
|
||||||
bundle install
|
|
||||||
|
|
||||||
- name: Create API Key
|
- name: Create API Key
|
||||||
env:
|
env:
|
||||||
API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
|
API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
|
||||||
|
|||||||
@@ -58,10 +58,6 @@ services:
|
|||||||
IMMICH_THIRD_PARTY_BUG_FEATURE_URL: https://github.com/immich-app/immich/issues
|
IMMICH_THIRD_PARTY_BUG_FEATURE_URL: https://github.com/immich-app/immich/issues
|
||||||
IMMICH_THIRD_PARTY_DOCUMENTATION_URL: https://docs.immich.app
|
IMMICH_THIRD_PARTY_DOCUMENTATION_URL: https://docs.immich.app
|
||||||
IMMICH_THIRD_PARTY_SUPPORT_URL: https://docs.immich.app/community-guides
|
IMMICH_THIRD_PARTY_SUPPORT_URL: https://docs.immich.app/community-guides
|
||||||
ulimits:
|
|
||||||
nofile:
|
|
||||||
soft: 1048576
|
|
||||||
hard: 1048576
|
|
||||||
ports:
|
ports:
|
||||||
- 9230:9230
|
- 9230:9230
|
||||||
- 9231:9231
|
- 9231:9231
|
||||||
@@ -100,10 +96,6 @@ services:
|
|||||||
- app-node_modules:/usr/src/app/node_modules
|
- app-node_modules:/usr/src/app/node_modules
|
||||||
- sveltekit:/usr/src/app/web/.svelte-kit
|
- sveltekit:/usr/src/app/web/.svelte-kit
|
||||||
- coverage:/usr/src/app/web/coverage
|
- coverage:/usr/src/app/web/coverage
|
||||||
ulimits:
|
|
||||||
nofile:
|
|
||||||
soft: 1048576
|
|
||||||
hard: 1048576
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
immich-server:
|
immich-server:
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ server {
|
|||||||
# disable buffering uploads to prevent OOM on reverse proxy server and make uploads twice as fast (no pause)
|
# disable buffering uploads to prevent OOM on reverse proxy server and make uploads twice as fast (no pause)
|
||||||
proxy_request_buffering off;
|
proxy_request_buffering off;
|
||||||
|
|
||||||
|
# increase body buffer to avoid limiting upload speed
|
||||||
|
client_body_buffer_size 1024k;
|
||||||
|
|
||||||
# Set headers
|
# Set headers
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
@@ -32,8 +35,6 @@ server {
|
|||||||
|
|
||||||
# enable websockets: http://nginx.org/en/docs/http/websocket.html
|
# enable websockets: http://nginx.org/en/docs/http/websocket.html
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
proxy_redirect off;
|
proxy_redirect off;
|
||||||
|
|
||||||
# set timeout
|
# set timeout
|
||||||
@@ -43,6 +44,8 @@ server {
|
|||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://<backend_url>:2283;
|
proxy_pass http://<backend_url>:2283;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
}
|
}
|
||||||
|
|
||||||
# useful when using Let's Encrypt http-01 challenge
|
# useful when using Let's Encrypt http-01 challenge
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-prettier": "^5.1.3",
|
"eslint-plugin-prettier": "^5.1.3",
|
||||||
"eslint-plugin-unicorn": "^62.0.0",
|
"eslint-plugin-unicorn": "^62.0.0",
|
||||||
"exiftool-vendored": "^33.0.0",
|
"exiftool-vendored": "^34.0.0",
|
||||||
"globals": "^16.0.0",
|
"globals": "^16.0.0",
|
||||||
"jose": "^5.6.3",
|
"jose": "^5.6.3",
|
||||||
"luxon": "^3.4.4",
|
"luxon": "^3.4.4",
|
||||||
|
|||||||
72
i18n/en.json
72
i18n/en.json
@@ -5,7 +5,6 @@
|
|||||||
"acknowledge": "Acknowledge",
|
"acknowledge": "Acknowledge",
|
||||||
"action": "Action",
|
"action": "Action",
|
||||||
"action_common_update": "Update",
|
"action_common_update": "Update",
|
||||||
"action_description": "A set of action to perform on the filtered assets",
|
|
||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
"active": "Active",
|
"active": "Active",
|
||||||
"active_count": "Active: {count}",
|
"active_count": "Active: {count}",
|
||||||
@@ -16,13 +15,9 @@
|
|||||||
"add_a_location": "Add a location",
|
"add_a_location": "Add a location",
|
||||||
"add_a_name": "Add a name",
|
"add_a_name": "Add a name",
|
||||||
"add_a_title": "Add a title",
|
"add_a_title": "Add a title",
|
||||||
"add_action": "Add action",
|
|
||||||
"add_action_description": "Click to add an action to perform",
|
|
||||||
"add_birthday": "Add a birthday",
|
"add_birthday": "Add a birthday",
|
||||||
"add_endpoint": "Add endpoint",
|
"add_endpoint": "Add endpoint",
|
||||||
"add_exclusion_pattern": "Add exclusion pattern",
|
"add_exclusion_pattern": "Add exclusion pattern",
|
||||||
"add_filter": "Add filter",
|
|
||||||
"add_filter_description": "Click to add a filter condition",
|
|
||||||
"add_location": "Add location",
|
"add_location": "Add location",
|
||||||
"add_more_users": "Add more users",
|
"add_more_users": "Add more users",
|
||||||
"add_partner": "Add partner",
|
"add_partner": "Add partner",
|
||||||
@@ -41,7 +36,6 @@
|
|||||||
"add_to_shared_album": "Add to shared album",
|
"add_to_shared_album": "Add to shared album",
|
||||||
"add_upload_to_stack": "Add upload to stack",
|
"add_upload_to_stack": "Add upload to stack",
|
||||||
"add_url": "Add URL",
|
"add_url": "Add URL",
|
||||||
"add_workflow_step": "Add workflow step",
|
|
||||||
"added_to_archive": "Added to archive",
|
"added_to_archive": "Added to archive",
|
||||||
"added_to_favorites": "Added to favorites",
|
"added_to_favorites": "Added to favorites",
|
||||||
"added_to_favorites_count": "Added {count, number} to favorites",
|
"added_to_favorites_count": "Added {count, number} to favorites",
|
||||||
@@ -473,7 +467,6 @@
|
|||||||
"album_remove_user": "Remove user?",
|
"album_remove_user": "Remove user?",
|
||||||
"album_remove_user_confirmation": "Are you sure you want to remove {user}?",
|
"album_remove_user_confirmation": "Are you sure you want to remove {user}?",
|
||||||
"album_search_not_found": "No albums found matching your search",
|
"album_search_not_found": "No albums found matching your search",
|
||||||
"album_selected": "Album selected",
|
|
||||||
"album_share_no_users": "Looks like you have shared this album with all users or you don't have any user to share with.",
|
"album_share_no_users": "Looks like you have shared this album with all users or you don't have any user to share with.",
|
||||||
"album_summary": "Album summary",
|
"album_summary": "Album summary",
|
||||||
"album_updated": "Album updated",
|
"album_updated": "Album updated",
|
||||||
@@ -495,7 +488,6 @@
|
|||||||
"albums_default_sort_order_description": "Initial asset sort order when creating new albums.",
|
"albums_default_sort_order_description": "Initial asset sort order when creating new albums.",
|
||||||
"albums_feature_description": "Collections of assets that can be shared with other users.",
|
"albums_feature_description": "Collections of assets that can be shared with other users.",
|
||||||
"albums_on_device_count": "Albums on device ({count})",
|
"albums_on_device_count": "Albums on device ({count})",
|
||||||
"albums_selected": "{count, plural, one {# album selected} other {# albums selected}}",
|
|
||||||
"all": "All",
|
"all": "All",
|
||||||
"all_albums": "All albums",
|
"all_albums": "All albums",
|
||||||
"all_people": "All people",
|
"all_people": "All people",
|
||||||
@@ -532,12 +524,10 @@
|
|||||||
"archived_count": "{count, plural, other {Archived #}}",
|
"archived_count": "{count, plural, other {Archived #}}",
|
||||||
"are_these_the_same_person": "Are these the same person?",
|
"are_these_the_same_person": "Are these the same person?",
|
||||||
"are_you_sure_to_do_this": "Are you sure you want to do this?",
|
"are_you_sure_to_do_this": "Are you sure you want to do this?",
|
||||||
"array_field_not_fully_supported": "Array fields require manual JSON editing",
|
|
||||||
"asset_action_delete_err_read_only": "Cannot delete read only asset(s), skipping",
|
"asset_action_delete_err_read_only": "Cannot delete read only asset(s), skipping",
|
||||||
"asset_action_share_err_offline": "Cannot fetch offline asset(s), skipping",
|
"asset_action_share_err_offline": "Cannot fetch offline asset(s), skipping",
|
||||||
"asset_added_to_album": "Added to album",
|
"asset_added_to_album": "Added to album",
|
||||||
"asset_adding_to_album": "Adding to album…",
|
"asset_adding_to_album": "Adding to album…",
|
||||||
"asset_created": "Asset created",
|
|
||||||
"asset_description_updated": "Asset description has been updated",
|
"asset_description_updated": "Asset description has been updated",
|
||||||
"asset_filename_is_offline": "Asset {filename} is offline",
|
"asset_filename_is_offline": "Asset {filename} is offline",
|
||||||
"asset_has_unassigned_faces": "Asset has unassigned faces",
|
"asset_has_unassigned_faces": "Asset has unassigned faces",
|
||||||
@@ -662,6 +652,7 @@
|
|||||||
"backup_options_page_title": "Backup options",
|
"backup_options_page_title": "Backup options",
|
||||||
"backup_setting_subtitle": "Manage background and foreground upload settings",
|
"backup_setting_subtitle": "Manage background and foreground upload settings",
|
||||||
"backup_settings_subtitle": "Manage upload settings",
|
"backup_settings_subtitle": "Manage upload settings",
|
||||||
|
"backup_upload_details_page_more_details": "Tap for more details",
|
||||||
"backward": "Backward",
|
"backward": "Backward",
|
||||||
"biometric_auth_enabled": "Biometric authentication enabled",
|
"biometric_auth_enabled": "Biometric authentication enabled",
|
||||||
"biometric_locked_out": "You are locked out of biometric authentication",
|
"biometric_locked_out": "You are locked out of biometric authentication",
|
||||||
@@ -720,8 +711,6 @@
|
|||||||
"change_password_form_password_mismatch": "Passwords do not match",
|
"change_password_form_password_mismatch": "Passwords do not match",
|
||||||
"change_password_form_reenter_new_password": "Re-enter New Password",
|
"change_password_form_reenter_new_password": "Re-enter New Password",
|
||||||
"change_pin_code": "Change PIN code",
|
"change_pin_code": "Change PIN code",
|
||||||
"change_trigger": "Change trigger",
|
|
||||||
"change_trigger_prompt": "Are you sure you want to change the trigger? This will remove all existing actions and filters.",
|
|
||||||
"change_your_password": "Change your password",
|
"change_your_password": "Change your password",
|
||||||
"changed_visibility_successfully": "Changed visibility successfully",
|
"changed_visibility_successfully": "Changed visibility successfully",
|
||||||
"charging": "Charging",
|
"charging": "Charging",
|
||||||
@@ -730,6 +719,7 @@
|
|||||||
"check_corrupt_asset_backup_button": "Perform check",
|
"check_corrupt_asset_backup_button": "Perform check",
|
||||||
"check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.",
|
"check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.",
|
||||||
"check_logs": "Check Logs",
|
"check_logs": "Check Logs",
|
||||||
|
"checksum": "Checksum",
|
||||||
"choose_matching_people_to_merge": "Choose matching people to merge",
|
"choose_matching_people_to_merge": "Choose matching people to merge",
|
||||||
"city": "City",
|
"city": "City",
|
||||||
"clear": "Clear",
|
"clear": "Clear",
|
||||||
@@ -797,7 +787,6 @@
|
|||||||
"create_album": "Create album",
|
"create_album": "Create album",
|
||||||
"create_album_page_untitled": "Untitled",
|
"create_album_page_untitled": "Untitled",
|
||||||
"create_api_key": "Create API key",
|
"create_api_key": "Create API key",
|
||||||
"create_first_workflow": "Create first workflow",
|
|
||||||
"create_library": "Create Library",
|
"create_library": "Create Library",
|
||||||
"create_link": "Create link",
|
"create_link": "Create link",
|
||||||
"create_link_to_share": "Create link to share",
|
"create_link_to_share": "Create link to share",
|
||||||
@@ -812,7 +801,6 @@
|
|||||||
"create_tag": "Create tag",
|
"create_tag": "Create tag",
|
||||||
"create_tag_description": "Create a new tag. For nested tags, please enter the full path of the tag including forward slashes.",
|
"create_tag_description": "Create a new tag. For nested tags, please enter the full path of the tag including forward slashes.",
|
||||||
"create_user": "Create user",
|
"create_user": "Create user",
|
||||||
"create_workflow": "Create workflow",
|
|
||||||
"created": "Created",
|
"created": "Created",
|
||||||
"created_at": "Created",
|
"created_at": "Created",
|
||||||
"creating_linked_albums": "Creating linked albums...",
|
"creating_linked_albums": "Creating linked albums...",
|
||||||
@@ -879,7 +867,6 @@
|
|||||||
"deselect_all": "Deselect All",
|
"deselect_all": "Deselect All",
|
||||||
"details": "Details",
|
"details": "Details",
|
||||||
"direction": "Direction",
|
"direction": "Direction",
|
||||||
"disable": "Disable",
|
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
"disallow_edits": "Disallow edits",
|
"disallow_edits": "Disallow edits",
|
||||||
"discord": "Discord",
|
"discord": "Discord",
|
||||||
@@ -942,13 +929,11 @@
|
|||||||
"edit_tag": "Edit tag",
|
"edit_tag": "Edit tag",
|
||||||
"edit_title": "Edit Title",
|
"edit_title": "Edit Title",
|
||||||
"edit_user": "Edit user",
|
"edit_user": "Edit user",
|
||||||
"edit_workflow": "Edit workflow",
|
|
||||||
"editor": "Editor",
|
"editor": "Editor",
|
||||||
"editor_close_without_save_prompt": "The changes will not be saved",
|
"editor_close_without_save_prompt": "The changes will not be saved",
|
||||||
"editor_close_without_save_title": "Close editor?",
|
"editor_close_without_save_title": "Close editor?",
|
||||||
"editor_crop_tool_h2_aspect_ratios": "Aspect ratios",
|
"editor_crop_tool_h2_aspect_ratios": "Aspect ratios",
|
||||||
"editor_crop_tool_h2_rotation": "Rotation",
|
"editor_crop_tool_h2_rotation": "Rotation",
|
||||||
"editor_mode": "Editor mode",
|
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
"email_notifications": "Email notifications",
|
"email_notifications": "Email notifications",
|
||||||
"empty_folder": "This folder is empty",
|
"empty_folder": "This folder is empty",
|
||||||
@@ -1029,7 +1014,6 @@
|
|||||||
"unable_to_complete_oauth_login": "Unable to complete OAuth login",
|
"unable_to_complete_oauth_login": "Unable to complete OAuth login",
|
||||||
"unable_to_connect": "Unable to connect",
|
"unable_to_connect": "Unable to connect",
|
||||||
"unable_to_copy_to_clipboard": "Cannot copy to clipboard, make sure you are accessing the page through https",
|
"unable_to_copy_to_clipboard": "Cannot copy to clipboard, make sure you are accessing the page through https",
|
||||||
"unable_to_create": "Unable to create workflow",
|
|
||||||
"unable_to_create_admin_account": "Unable to create admin account",
|
"unable_to_create_admin_account": "Unable to create admin account",
|
||||||
"unable_to_create_api_key": "Unable to create a new API Key",
|
"unable_to_create_api_key": "Unable to create a new API Key",
|
||||||
"unable_to_create_library": "Unable to create library",
|
"unable_to_create_library": "Unable to create library",
|
||||||
@@ -1040,7 +1024,6 @@
|
|||||||
"unable_to_delete_exclusion_pattern": "Unable to delete exclusion pattern",
|
"unable_to_delete_exclusion_pattern": "Unable to delete exclusion pattern",
|
||||||
"unable_to_delete_shared_link": "Unable to delete shared link",
|
"unable_to_delete_shared_link": "Unable to delete shared link",
|
||||||
"unable_to_delete_user": "Unable to delete user",
|
"unable_to_delete_user": "Unable to delete user",
|
||||||
"unable_to_delete_workflow": "Unable to delete workflow",
|
|
||||||
"unable_to_download_files": "Unable to download files",
|
"unable_to_download_files": "Unable to download files",
|
||||||
"unable_to_edit_exclusion_pattern": "Unable to edit exclusion pattern",
|
"unable_to_edit_exclusion_pattern": "Unable to edit exclusion pattern",
|
||||||
"unable_to_empty_trash": "Unable to empty trash",
|
"unable_to_empty_trash": "Unable to empty trash",
|
||||||
@@ -1091,7 +1074,6 @@
|
|||||||
"unable_to_update_settings": "Unable to update settings",
|
"unable_to_update_settings": "Unable to update settings",
|
||||||
"unable_to_update_timeline_display_status": "Unable to update timeline display status",
|
"unable_to_update_timeline_display_status": "Unable to update timeline display status",
|
||||||
"unable_to_update_user": "Unable to update user",
|
"unable_to_update_user": "Unable to update user",
|
||||||
"unable_to_update_workflow": "Unable to update workflow",
|
|
||||||
"unable_to_upload_file": "Unable to upload file"
|
"unable_to_upload_file": "Unable to upload file"
|
||||||
},
|
},
|
||||||
"exclusion_pattern": "Exclusion pattern",
|
"exclusion_pattern": "Exclusion pattern",
|
||||||
@@ -1144,10 +1126,8 @@
|
|||||||
"filename": "Filename",
|
"filename": "Filename",
|
||||||
"filetype": "Filetype",
|
"filetype": "Filetype",
|
||||||
"filter": "Filter",
|
"filter": "Filter",
|
||||||
"filter_description": "Conditions to filter the target assets",
|
|
||||||
"filter_people": "Filter people",
|
"filter_people": "Filter people",
|
||||||
"filter_places": "Filter places",
|
"filter_places": "Filter places",
|
||||||
"filters": "Filters",
|
|
||||||
"find_them_fast": "Find them fast by name with search",
|
"find_them_fast": "Find them fast by name with search",
|
||||||
"first": "First",
|
"first": "First",
|
||||||
"fix_incorrect_match": "Fix incorrect match",
|
"fix_incorrect_match": "Fix incorrect match",
|
||||||
@@ -1163,7 +1143,6 @@
|
|||||||
"general": "General",
|
"general": "General",
|
||||||
"geolocation_instruction_location": "Click on an asset with GPS coordinates to use its location, or select a location directly from the map",
|
"geolocation_instruction_location": "Click on an asset with GPS coordinates to use its location, or select a location directly from the map",
|
||||||
"get_help": "Get Help",
|
"get_help": "Get Help",
|
||||||
"get_people_error": "Error getting people",
|
|
||||||
"get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network",
|
"get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network",
|
||||||
"getting_started": "Getting Started",
|
"getting_started": "Getting Started",
|
||||||
"go_back": "Go back",
|
"go_back": "Go back",
|
||||||
@@ -1189,13 +1168,13 @@
|
|||||||
"header_settings_header_name_input": "Header name",
|
"header_settings_header_name_input": "Header name",
|
||||||
"header_settings_header_value_input": "Header value",
|
"header_settings_header_value_input": "Header value",
|
||||||
"headers_settings_tile_title": "Custom proxy headers",
|
"headers_settings_tile_title": "Custom proxy headers",
|
||||||
|
"height": "Height",
|
||||||
"hi_user": "Hi {name} ({email})",
|
"hi_user": "Hi {name} ({email})",
|
||||||
"hide_all_people": "Hide all people",
|
"hide_all_people": "Hide all people",
|
||||||
"hide_gallery": "Hide gallery",
|
"hide_gallery": "Hide gallery",
|
||||||
"hide_named_person": "Hide person {name}",
|
"hide_named_person": "Hide person {name}",
|
||||||
"hide_password": "Hide password",
|
"hide_password": "Hide password",
|
||||||
"hide_person": "Hide person",
|
"hide_person": "Hide person",
|
||||||
"hide_schema": "Hide schema",
|
|
||||||
"hide_text_recognition": "Hide text recognition",
|
"hide_text_recognition": "Hide text recognition",
|
||||||
"hide_unnamed_people": "Hide unnamed people",
|
"hide_unnamed_people": "Hide unnamed people",
|
||||||
"home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.",
|
"home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.",
|
||||||
@@ -1268,8 +1247,6 @@
|
|||||||
"ios_debug_info_processing_ran_at": "Processing ran {dateTime}",
|
"ios_debug_info_processing_ran_at": "Processing ran {dateTime}",
|
||||||
"items_count": "{count, plural, one {# item} other {# items}}",
|
"items_count": "{count, plural, one {# item} other {# items}}",
|
||||||
"jobs": "Jobs",
|
"jobs": "Jobs",
|
||||||
"json_editor": "JSON editor",
|
|
||||||
"json_error": "JSON error",
|
|
||||||
"keep": "Keep",
|
"keep": "Keep",
|
||||||
"keep_all": "Keep All",
|
"keep_all": "Keep All",
|
||||||
"keep_this_delete_others": "Keep this, delete others",
|
"keep_this_delete_others": "Keep this, delete others",
|
||||||
@@ -1314,6 +1291,7 @@
|
|||||||
"local": "Local",
|
"local": "Local",
|
||||||
"local_asset_cast_failed": "Unable to cast an asset that is not uploaded to the server",
|
"local_asset_cast_failed": "Unable to cast an asset that is not uploaded to the server",
|
||||||
"local_assets": "Local Assets",
|
"local_assets": "Local Assets",
|
||||||
|
"local_id": "Local ID",
|
||||||
"local_media_summary": "Local Media Summary",
|
"local_media_summary": "Local Media Summary",
|
||||||
"local_network": "Local network",
|
"local_network": "Local network",
|
||||||
"local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network",
|
"local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network",
|
||||||
@@ -1438,13 +1416,11 @@
|
|||||||
"monthly_title_text_date_format": "MMMM y",
|
"monthly_title_text_date_format": "MMMM y",
|
||||||
"more": "More",
|
"more": "More",
|
||||||
"move": "Move",
|
"move": "Move",
|
||||||
"move_down": "Move down",
|
|
||||||
"move_off_locked_folder": "Move out of locked folder",
|
"move_off_locked_folder": "Move out of locked folder",
|
||||||
"move_to": "Move to",
|
"move_to": "Move to",
|
||||||
"move_to_lock_folder_action_prompt": "{count} added to the locked folder",
|
"move_to_lock_folder_action_prompt": "{count} added to the locked folder",
|
||||||
"move_to_locked_folder": "Move to locked folder",
|
"move_to_locked_folder": "Move to locked folder",
|
||||||
"move_to_locked_folder_confirmation": "These photos and video will be removed from all albums, and only viewable from the locked folder",
|
"move_to_locked_folder_confirmation": "These photos and video will be removed from all albums, and only viewable from the locked folder",
|
||||||
"move_up": "Move up",
|
|
||||||
"moved_to_archive": "Moved {count, plural, one {# asset} other {# assets}} to archive",
|
"moved_to_archive": "Moved {count, plural, one {# asset} other {# assets}} to archive",
|
||||||
"moved_to_library": "Moved {count, plural, one {# asset} other {# assets}} to library",
|
"moved_to_library": "Moved {count, plural, one {# asset} other {# assets}} to library",
|
||||||
"moved_to_trash": "Moved to trash",
|
"moved_to_trash": "Moved to trash",
|
||||||
@@ -1454,7 +1430,6 @@
|
|||||||
"my_albums": "My albums",
|
"my_albums": "My albums",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"name_or_nickname": "Name or nickname",
|
"name_or_nickname": "Name or nickname",
|
||||||
"name_required": "Name is required",
|
|
||||||
"navigate": "Navigate",
|
"navigate": "Navigate",
|
||||||
"navigate_to_time": "Navigate to Time",
|
"navigate_to_time": "Navigate to Time",
|
||||||
"network_requirement_photos_upload": "Use cellular data to backup photos",
|
"network_requirement_photos_upload": "Use cellular data to backup photos",
|
||||||
@@ -1479,7 +1454,6 @@
|
|||||||
"next": "Next",
|
"next": "Next",
|
||||||
"next_memory": "Next memory",
|
"next_memory": "Next memory",
|
||||||
"no": "No",
|
"no": "No",
|
||||||
"no_actions_added": "No actions added yet",
|
|
||||||
"no_albums_message": "Create an album to organize your photos and videos",
|
"no_albums_message": "Create an album to organize your photos and videos",
|
||||||
"no_albums_with_name_yet": "It looks like you do not have any albums with this name yet.",
|
"no_albums_with_name_yet": "It looks like you do not have any albums with this name yet.",
|
||||||
"no_albums_yet": "It looks like you do not have any albums yet.",
|
"no_albums_yet": "It looks like you do not have any albums yet.",
|
||||||
@@ -1489,13 +1463,11 @@
|
|||||||
"no_cast_devices_found": "No cast devices found",
|
"no_cast_devices_found": "No cast devices found",
|
||||||
"no_checksum_local": "No checksum available - cannot fetch local assets",
|
"no_checksum_local": "No checksum available - cannot fetch local assets",
|
||||||
"no_checksum_remote": "No checksum available - cannot fetch remote asset",
|
"no_checksum_remote": "No checksum available - cannot fetch remote asset",
|
||||||
"no_configuration_needed": "No configuration needed",
|
|
||||||
"no_devices": "No authorized devices",
|
"no_devices": "No authorized devices",
|
||||||
"no_duplicates_found": "No duplicates were found.",
|
"no_duplicates_found": "No duplicates were found.",
|
||||||
"no_exif_info_available": "No exif info available",
|
"no_exif_info_available": "No exif info available",
|
||||||
"no_explore_results_message": "Upload more photos to explore your collection.",
|
"no_explore_results_message": "Upload more photos to explore your collection.",
|
||||||
"no_favorites_message": "Add favorites to quickly find your best pictures and videos",
|
"no_favorites_message": "Add favorites to quickly find your best pictures and videos",
|
||||||
"no_filters_added": "No filters added yet",
|
|
||||||
"no_libraries_message": "Create an external library to view your photos and videos",
|
"no_libraries_message": "Create an external library to view your photos and videos",
|
||||||
"no_local_assets_found": "No local assets found with this checksum",
|
"no_local_assets_found": "No local assets found with this checksum",
|
||||||
"no_location_set": "No location set",
|
"no_location_set": "No location set",
|
||||||
@@ -1591,7 +1563,6 @@
|
|||||||
"people": "People",
|
"people": "People",
|
||||||
"people_edits_count": "Edited {count, plural, one {# person} other {# people}}",
|
"people_edits_count": "Edited {count, plural, one {# person} other {# people}}",
|
||||||
"people_feature_description": "Browsing photos and videos grouped by people",
|
"people_feature_description": "Browsing photos and videos grouped by people",
|
||||||
"people_selected": "{count, plural, one {# person selected} other {# people selected}}",
|
|
||||||
"people_sidebar_description": "Display a link to People in the sidebar",
|
"people_sidebar_description": "Display a link to People in the sidebar",
|
||||||
"permanent_deletion_warning": "Permanent deletion warning",
|
"permanent_deletion_warning": "Permanent deletion warning",
|
||||||
"permanent_deletion_warning_setting_description": "Show a warning when permanently deleting assets",
|
"permanent_deletion_warning_setting_description": "Show a warning when permanently deleting assets",
|
||||||
@@ -1616,8 +1587,6 @@
|
|||||||
"person_age_years": "{years, plural, other {# years}} old",
|
"person_age_years": "{years, plural, other {# years}} old",
|
||||||
"person_birthdate": "Born on {date}",
|
"person_birthdate": "Born on {date}",
|
||||||
"person_hidden": "{name}{hidden, select, true { (hidden)} other {}}",
|
"person_hidden": "{name}{hidden, select, true { (hidden)} other {}}",
|
||||||
"person_recognized": "Person recognized",
|
|
||||||
"person_selected": "Person selected",
|
|
||||||
"photo_shared_all_users": "Looks like you shared your photos with all users or you don't have any user to share with.",
|
"photo_shared_all_users": "Looks like you shared your photos with all users or you don't have any user to share with.",
|
||||||
"photos": "Photos",
|
"photos": "Photos",
|
||||||
"photos_and_videos": "Photos & Videos",
|
"photos_and_videos": "Photos & Videos",
|
||||||
@@ -1867,22 +1836,17 @@
|
|||||||
"second": "Second",
|
"second": "Second",
|
||||||
"see_all_people": "See all people",
|
"see_all_people": "See all people",
|
||||||
"select": "Select",
|
"select": "Select",
|
||||||
"select_album": "Select album",
|
|
||||||
"select_album_cover": "Select album cover",
|
"select_album_cover": "Select album cover",
|
||||||
"select_albums": "Select albums",
|
|
||||||
"select_all": "Select all",
|
"select_all": "Select all",
|
||||||
"select_all_duplicates": "Select all duplicates",
|
"select_all_duplicates": "Select all duplicates",
|
||||||
"select_all_in": "Select all in {group}",
|
"select_all_in": "Select all in {group}",
|
||||||
"select_avatar_color": "Select avatar color",
|
"select_avatar_color": "Select avatar color",
|
||||||
"select_count": "{count, plural, one {Select #} other {Select #}}",
|
|
||||||
"select_face": "Select face",
|
"select_face": "Select face",
|
||||||
"select_featured_photo": "Select featured photo",
|
"select_featured_photo": "Select featured photo",
|
||||||
"select_from_computer": "Select from computer",
|
"select_from_computer": "Select from computer",
|
||||||
"select_keep_all": "Select keep all",
|
"select_keep_all": "Select keep all",
|
||||||
"select_library_owner": "Select library owner",
|
"select_library_owner": "Select library owner",
|
||||||
"select_new_face": "Select new face",
|
"select_new_face": "Select new face",
|
||||||
"select_people": "Select people",
|
|
||||||
"select_person": "Select person",
|
|
||||||
"select_person_to_tag": "Select a person to tag",
|
"select_person_to_tag": "Select a person to tag",
|
||||||
"select_photos": "Select photos",
|
"select_photos": "Select photos",
|
||||||
"select_trash_all": "Select trash all",
|
"select_trash_all": "Select trash all",
|
||||||
@@ -2018,7 +1982,6 @@
|
|||||||
"show_password": "Show password",
|
"show_password": "Show password",
|
||||||
"show_person_options": "Show person options",
|
"show_person_options": "Show person options",
|
||||||
"show_progress_bar": "Show Progress Bar",
|
"show_progress_bar": "Show Progress Bar",
|
||||||
"show_schema": "Show schema",
|
|
||||||
"show_search_options": "Show search options",
|
"show_search_options": "Show search options",
|
||||||
"show_shared_links": "Show shared links",
|
"show_shared_links": "Show shared links",
|
||||||
"show_slideshow_transition": "Show slideshow transition",
|
"show_slideshow_transition": "Show slideshow transition",
|
||||||
@@ -2146,13 +2109,6 @@
|
|||||||
"trash_page_select_assets_btn": "Select assets",
|
"trash_page_select_assets_btn": "Select assets",
|
||||||
"trash_page_title": "Trash ({count})",
|
"trash_page_title": "Trash ({count})",
|
||||||
"trashed_items_will_be_permanently_deleted_after": "Trashed items will be permanently deleted after {days, plural, one {# day} other {# days}}.",
|
"trashed_items_will_be_permanently_deleted_after": "Trashed items will be permanently deleted after {days, plural, one {# day} other {# days}}.",
|
||||||
"trigger": "Trigger",
|
|
||||||
"trigger_asset_uploaded": "Asset Uploaded",
|
|
||||||
"trigger_asset_uploaded_description": "Triggered when a new asset is uploaded",
|
|
||||||
"trigger_description": "An event that kick off the workflow",
|
|
||||||
"trigger_person_recognized": "Person Recognized",
|
|
||||||
"trigger_person_recognized_description": "Triggered when a person is detected",
|
|
||||||
"trigger_type": "Trigger type",
|
|
||||||
"troubleshoot": "Troubleshoot",
|
"troubleshoot": "Troubleshoot",
|
||||||
"type": "Type",
|
"type": "Type",
|
||||||
"unable_to_change_pin_code": "Unable to change PIN code",
|
"unable_to_change_pin_code": "Unable to change PIN code",
|
||||||
@@ -2183,9 +2139,7 @@
|
|||||||
"unstack": "Un-stack",
|
"unstack": "Un-stack",
|
||||||
"unstack_action_prompt": "{count} unstacked",
|
"unstack_action_prompt": "{count} unstacked",
|
||||||
"unstacked_assets_count": "Un-stacked {count, plural, one {# asset} other {# assets}}",
|
"unstacked_assets_count": "Un-stacked {count, plural, one {# asset} other {# assets}}",
|
||||||
"unsupported_field_type": "Unsupported field type",
|
|
||||||
"untagged": "Untagged",
|
"untagged": "Untagged",
|
||||||
"untitled_workflow": "Untitled workflow",
|
|
||||||
"up_next": "Up next",
|
"up_next": "Up next",
|
||||||
"update_location_action_prompt": "Update the location of {count} selected assets with:",
|
"update_location_action_prompt": "Update the location of {count} selected assets with:",
|
||||||
"updated_at": "Updated",
|
"updated_at": "Updated",
|
||||||
@@ -2231,7 +2185,6 @@
|
|||||||
"utilities": "Utilities",
|
"utilities": "Utilities",
|
||||||
"validate": "Validate",
|
"validate": "Validate",
|
||||||
"validate_endpoint_error": "Please enter a valid URL",
|
"validate_endpoint_error": "Please enter a valid URL",
|
||||||
"validation_error": "Validation error",
|
|
||||||
"variables": "Variables",
|
"variables": "Variables",
|
||||||
"version": "Version",
|
"version": "Version",
|
||||||
"version_announcement_closing": "Your friend, Alex",
|
"version_announcement_closing": "Your friend, Alex",
|
||||||
@@ -2263,28 +2216,15 @@
|
|||||||
"viewer_stack_use_as_main_asset": "Use as Main Asset",
|
"viewer_stack_use_as_main_asset": "Use as Main Asset",
|
||||||
"viewer_unstack": "Un-Stack",
|
"viewer_unstack": "Un-Stack",
|
||||||
"visibility_changed": "Visibility changed for {count, plural, one {# person} other {# people}}",
|
"visibility_changed": "Visibility changed for {count, plural, one {# person} other {# people}}",
|
||||||
"visual": "Visual",
|
|
||||||
"visual_builder": "Visual builder",
|
|
||||||
"waiting": "Waiting",
|
"waiting": "Waiting",
|
||||||
"waiting_count": "Waiting: {count}",
|
"waiting_count": "Waiting: {count}",
|
||||||
"warning": "Warning",
|
"warning": "Warning",
|
||||||
"week": "Week",
|
"week": "Week",
|
||||||
"welcome": "Welcome",
|
"welcome": "Welcome",
|
||||||
"welcome_to_immich": "Welcome to Immich",
|
"welcome_to_immich": "Welcome to Immich",
|
||||||
|
"width": "Width",
|
||||||
"wifi_name": "Wi-Fi Name",
|
"wifi_name": "Wi-Fi Name",
|
||||||
"workflow_delete_prompt": "Are you sure you want to delete this workflow?",
|
"workflow": "Workflow",
|
||||||
"workflow_deleted": "Workflow deleted",
|
|
||||||
"workflow_description": "Workflow description",
|
|
||||||
"workflow_info": "Workflow info",
|
|
||||||
"workflow_json": "Workflow JSON",
|
|
||||||
"workflow_json_help": "Edit the workflow configuration in JSON format. Changes will sync to the visual builder.",
|
|
||||||
"workflow_name": "Workflow name",
|
|
||||||
"workflow_navigation_prompt": "Are you sure you want to leave without saving your changes?",
|
|
||||||
"workflow_summary": "Workflow summary",
|
|
||||||
"workflow_update_success": "Workflow updated successfully",
|
|
||||||
"workflow_updated": "Workflow updated",
|
|
||||||
"workflows": "Workflows",
|
|
||||||
"workflows_help_text": "Workflows automate actions on your assets based on triggers and filters",
|
|
||||||
"wrong_pin_code": "Wrong PIN code",
|
"wrong_pin_code": "Wrong PIN code",
|
||||||
"year": "Year",
|
"year": "Year",
|
||||||
"years_ago": "{years, plural, one {# year} other {# years}} ago",
|
"years_ago": "{years, plural, one {# year} other {# years}} ago",
|
||||||
|
|||||||
@@ -137,8 +137,7 @@
|
|||||||
<key>NSFaceIDUsageDescription</key>
|
<key>NSFaceIDUsageDescription</key>
|
||||||
<string>We need to use FaceID to allow access to your locked folder</string>
|
<string>We need to use FaceID to allow access to your locked folder</string>
|
||||||
<key>NSLocalNetworkUsageDescription</key>
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
<string>We need local network permission to connect to the local server using IP address and
|
<string>We need local network permission to connect to the local server using IP address and allow the casting feature to work</string>
|
||||||
allow the casting feature to work</string>
|
|
||||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||||
<string>We require this permission to access the local WiFi name for background upload mechanism</string>
|
<string>We require this permission to access the local WiFi name for background upload mechanism</string>
|
||||||
<key>NSLocationUsageDescription</key>
|
<key>NSLocationUsageDescription</key>
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ class DriftUploadDetailPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'Tap for more details',
|
"backup_upload_details_page_more_details".t(context: context),
|
||||||
style: context.textTheme.bodySmall?.copyWith(
|
style: context.textTheme.bodySmall?.copyWith(
|
||||||
color: context.colorScheme.onSurface.withValues(alpha: 0.6),
|
color: context.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||||
),
|
),
|
||||||
@@ -239,14 +239,20 @@ class FileDetailDialog extends ConsumerWidget {
|
|||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
if (asset != null) ...[
|
if (asset != null) ...[
|
||||||
_buildInfoSection(context, [
|
_buildInfoSection(context, [
|
||||||
_buildInfoRow(context, "Filename", path.basename(uploadStatus.filename)),
|
_buildInfoRow(context, "filename".t(context: context), path.basename(uploadStatus.filename)),
|
||||||
_buildInfoRow(context, "Local ID", asset.id),
|
_buildInfoRow(context, "local_id".t(context: context), asset.id),
|
||||||
_buildInfoRow(context, "File Size", formatHumanReadableBytes(uploadStatus.fileSize, 2)),
|
_buildInfoRow(
|
||||||
if (asset.width != null) _buildInfoRow(context, "Width", "${asset.width}px"),
|
context,
|
||||||
if (asset.height != null) _buildInfoRow(context, "Height", "${asset.height}px"),
|
"file_size".t(context: context),
|
||||||
_buildInfoRow(context, "Created At", asset.createdAt.toString()),
|
formatHumanReadableBytes(uploadStatus.fileSize, 2),
|
||||||
_buildInfoRow(context, "Updated At", asset.updatedAt.toString()),
|
),
|
||||||
if (asset.checksum != null) _buildInfoRow(context, "Checksum", asset.checksum!),
|
if (asset.width != null) _buildInfoRow(context, "width".t(context: context), "${asset.width}px"),
|
||||||
|
if (asset.height != null)
|
||||||
|
_buildInfoRow(context, "height".t(context: context), "${asset.height}px"),
|
||||||
|
_buildInfoRow(context, "created_at".t(context: context), asset.createdAt.toString()),
|
||||||
|
_buildInfoRow(context, "updated_at".t(context: context), asset.updatedAt.toString()),
|
||||||
|
if (asset.checksum != null)
|
||||||
|
_buildInfoRow(context, "checksum".t(context: context), asset.checksum!),
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,173 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:auto_route/auto_route.dart';
|
|
||||||
import 'package:drift/drift.dart' hide Column;
|
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
|
||||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
|
||||||
import 'package:logging/logging.dart';
|
|
||||||
|
|
||||||
final _features = [
|
|
||||||
_Feature(
|
|
||||||
name: 'Main Timeline',
|
|
||||||
icon: Icons.timeline_rounded,
|
|
||||||
onTap: (ctx, _) => ctx.pushRoute(const TabShellRoute()),
|
|
||||||
),
|
|
||||||
_Feature(
|
|
||||||
name: 'Selection Mode Timeline',
|
|
||||||
icon: Icons.developer_mode_rounded,
|
|
||||||
onTap: (ctx, ref) async {
|
|
||||||
final user = ref.watch(currentUserProvider);
|
|
||||||
if (user == null) {
|
|
||||||
return Future.value();
|
|
||||||
}
|
|
||||||
|
|
||||||
final assets = await ref.read(remoteAssetRepositoryProvider).getSome(user.id);
|
|
||||||
|
|
||||||
final selectedAssets = await ctx.pushRoute<Set<BaseAsset>>(
|
|
||||||
DriftAssetSelectionTimelineRoute(lockedSelectionAssets: assets.toSet()),
|
|
||||||
);
|
|
||||||
|
|
||||||
Logger("FeaturesInDevelopment").fine("Selected ${selectedAssets?.length ?? 0} assets");
|
|
||||||
|
|
||||||
return Future.value();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
_Feature(name: '', icon: Icons.vertical_align_center_sharp, onTap: (_, __) => Future.value()),
|
|
||||||
_Feature(
|
|
||||||
name: 'Sync Local',
|
|
||||||
icon: Icons.photo_album_rounded,
|
|
||||||
onTap: (_, ref) => ref.read(backgroundSyncProvider).syncLocal(),
|
|
||||||
),
|
|
||||||
_Feature(
|
|
||||||
name: 'Sync Local Full (1)',
|
|
||||||
icon: Icons.photo_library_rounded,
|
|
||||||
onTap: (_, ref) => ref.read(backgroundSyncProvider).syncLocal(full: true),
|
|
||||||
),
|
|
||||||
_Feature(
|
|
||||||
name: 'Hash Local Assets (2)',
|
|
||||||
icon: Icons.numbers_outlined,
|
|
||||||
onTap: (_, ref) => ref.read(backgroundSyncProvider).hashAssets(),
|
|
||||||
),
|
|
||||||
_Feature(
|
|
||||||
name: 'Sync Remote (3)',
|
|
||||||
icon: Icons.refresh_rounded,
|
|
||||||
onTap: (_, ref) => ref.read(backgroundSyncProvider).syncRemote(),
|
|
||||||
),
|
|
||||||
_Feature(
|
|
||||||
name: 'WAL Checkpoint',
|
|
||||||
icon: Icons.save_rounded,
|
|
||||||
onTap: (_, ref) => ref.read(driftProvider).customStatement("pragma wal_checkpoint(truncate)"),
|
|
||||||
),
|
|
||||||
_Feature(name: '', icon: Icons.vertical_align_center_sharp, onTap: (_, __) => Future.value()),
|
|
||||||
_Feature(
|
|
||||||
name: 'Clear Delta Checkpoint',
|
|
||||||
icon: Icons.delete_rounded,
|
|
||||||
onTap: (_, ref) => ref.read(nativeSyncApiProvider).clearSyncCheckpoint(),
|
|
||||||
),
|
|
||||||
_Feature(
|
|
||||||
name: 'Clear Local Data',
|
|
||||||
style: const TextStyle(color: Colors.orange, fontWeight: FontWeight.bold),
|
|
||||||
icon: Icons.delete_forever_rounded,
|
|
||||||
onTap: (_, ref) async {
|
|
||||||
final db = ref.read(driftProvider);
|
|
||||||
await db.localAssetEntity.deleteAll();
|
|
||||||
await db.localAlbumEntity.deleteAll();
|
|
||||||
await db.localAlbumAssetEntity.deleteAll();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
_Feature(
|
|
||||||
name: 'Clear Remote Data',
|
|
||||||
style: const TextStyle(color: Colors.orange, fontWeight: FontWeight.bold),
|
|
||||||
icon: Icons.delete_sweep_rounded,
|
|
||||||
onTap: (_, ref) async {
|
|
||||||
final db = ref.read(driftProvider);
|
|
||||||
await db.remoteAssetEntity.deleteAll();
|
|
||||||
await db.remoteExifEntity.deleteAll();
|
|
||||||
await db.remoteAlbumEntity.deleteAll();
|
|
||||||
await db.remoteAlbumUserEntity.deleteAll();
|
|
||||||
await db.remoteAlbumAssetEntity.deleteAll();
|
|
||||||
await db.memoryEntity.deleteAll();
|
|
||||||
await db.memoryAssetEntity.deleteAll();
|
|
||||||
await db.stackEntity.deleteAll();
|
|
||||||
await db.personEntity.deleteAll();
|
|
||||||
await db.assetFaceEntity.deleteAll();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
_Feature(
|
|
||||||
name: 'Local Media Summary',
|
|
||||||
style: const TextStyle(color: Colors.indigo, fontWeight: FontWeight.bold),
|
|
||||||
icon: Icons.table_chart_rounded,
|
|
||||||
onTap: (ctx, _) => ctx.pushRoute(const LocalMediaSummaryRoute()),
|
|
||||||
),
|
|
||||||
_Feature(
|
|
||||||
name: 'Remote Media Summary',
|
|
||||||
style: const TextStyle(color: Colors.indigo, fontWeight: FontWeight.bold),
|
|
||||||
icon: Icons.summarize_rounded,
|
|
||||||
onTap: (ctx, _) => ctx.pushRoute(const RemoteMediaSummaryRoute()),
|
|
||||||
),
|
|
||||||
_Feature(
|
|
||||||
name: 'Reset Sqlite',
|
|
||||||
icon: Icons.table_view_rounded,
|
|
||||||
style: const TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
|
|
||||||
onTap: (_, ref) async {
|
|
||||||
final drift = ref.read(driftProvider);
|
|
||||||
// ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member
|
|
||||||
final migrator = drift.createMigrator();
|
|
||||||
for (final entity in drift.allSchemaEntities) {
|
|
||||||
await migrator.drop(entity);
|
|
||||||
await migrator.create(entity);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
@RoutePage()
|
|
||||||
class FeatInDevPage extends StatelessWidget {
|
|
||||||
const FeatInDevPage({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(title: Text('features_in_development'.tr()), centerTitle: true),
|
|
||||||
body: Column(
|
|
||||||
children: [
|
|
||||||
Flexible(
|
|
||||||
flex: 1,
|
|
||||||
child: ListView.builder(
|
|
||||||
itemBuilder: (_, index) {
|
|
||||||
final feat = _features[index];
|
|
||||||
return Consumer(
|
|
||||||
builder: (ctx, ref, _) => ListTile(
|
|
||||||
title: Text(feat.name, style: feat.style),
|
|
||||||
trailing: Icon(feat.icon),
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
onTap: () => unawaited(feat.onTap(ctx, ref)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
itemCount: _features.length,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Divider(height: 0),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _Feature {
|
|
||||||
const _Feature({required this.name, required this.icon, required this.onTap, this.style});
|
|
||||||
|
|
||||||
final String name;
|
|
||||||
final IconData icon;
|
|
||||||
final TextStyle? style;
|
|
||||||
final Future<void> Function(BuildContext, WidgetRef _) onTap;
|
|
||||||
}
|
|
||||||
51
mobile/lib/presentation/pages/dev/ui_showcase.page.dart
Normal file
51
mobile/lib/presentation/pages/dev/ui_showcase.page.dart
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:immich_ui/immich_ui.dart';
|
||||||
|
|
||||||
|
List<Widget> _showcaseBuilder(Function(ImmichVariant variant, ImmichColor color) builder) {
|
||||||
|
final children = <Widget>[];
|
||||||
|
|
||||||
|
final items = [
|
||||||
|
(variant: ImmichVariant.filled, title: "Filled Variant"),
|
||||||
|
(variant: ImmichVariant.ghost, title: "Ghost Variant"),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (final (:variant, :title) in items) {
|
||||||
|
children.add(Text(title));
|
||||||
|
children.add(Row(spacing: 10, children: [for (var color in ImmichColor.values) builder(variant, color)]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
@RoutePage()
|
||||||
|
class ImmichUIShowcasePage extends StatelessWidget {
|
||||||
|
const ImmichUIShowcasePage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Immich UI Showcase')),
|
||||||
|
body: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
spacing: 10,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text("IconButton", style: context.textTheme.titleLarge),
|
||||||
|
..._showcaseBuilder(
|
||||||
|
(variant, color) =>
|
||||||
|
ImmichIconButton(icon: Icons.favorite, color: color, variant: variant, onTap: () {}),
|
||||||
|
),
|
||||||
|
Text("CloseButton", style: context.textTheme.titleLarge),
|
||||||
|
..._showcaseBuilder((variant, color) => ImmichCloseButton(color: color, variant: variant, onTap: () {})),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,7 +37,7 @@ class DriftActivitiesPage extends HookConsumerWidget {
|
|||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(album.name),
|
title: Text(album.name),
|
||||||
actions: [const LikeActivityActionButton(menuItem: true)],
|
actions: [const LikeActivityActionButton(iconOnly: true)],
|
||||||
actionsPadding: const EdgeInsets.only(right: 8),
|
actionsPadding: const EdgeInsets.only(right: 8),
|
||||||
),
|
),
|
||||||
body: activities.widgetWhen(
|
body: activities.widgetWhen(
|
||||||
|
|||||||
@@ -27,8 +27,19 @@ class _DriftCreateAlbumPageState extends ConsumerState<DriftCreateAlbumPage> {
|
|||||||
bool isAlbumTitleTextFieldFocus = false;
|
bool isAlbumTitleTextFieldFocus = false;
|
||||||
Set<BaseAsset> selectedAssets = {};
|
Set<BaseAsset> selectedAssets = {};
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
albumTitleController.addListener(_onTitleChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onTitleChanged() {
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
albumTitleController.removeListener(_onTitleChanged);
|
||||||
albumTitleController.dispose();
|
albumTitleController.dispose();
|
||||||
albumDescriptionController.dispose();
|
albumDescriptionController.dispose();
|
||||||
albumTitleTextFieldFocusNode.dispose();
|
albumTitleTextFieldFocusNode.dispose();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
|||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/utils/hooks/crop_controller_hook.dart';
|
import 'package:immich_mobile/utils/hooks/crop_controller_hook.dart';
|
||||||
|
import 'package:immich_ui/immich_ui.dart';
|
||||||
|
|
||||||
/// A widget for cropping an image.
|
/// A widget for cropping an image.
|
||||||
/// This widget uses [HookWidget] to manage its lifecycle and state. It allows
|
/// This widget uses [HookWidget] to manage its lifecycle and state. It allows
|
||||||
@@ -30,11 +31,13 @@ class DriftCropImagePage extends HookWidget {
|
|||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: context.scaffoldBackgroundColor,
|
backgroundColor: context.scaffoldBackgroundColor,
|
||||||
title: Text("crop".tr()),
|
title: Text("crop".tr()),
|
||||||
leading: CloseButton(color: context.primaryColor),
|
leading: const ImmichCloseButton(),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
ImmichIconButton(
|
||||||
icon: Icon(Icons.done_rounded, color: context.primaryColor, size: 24),
|
icon: Icons.done_rounded,
|
||||||
onPressed: () async {
|
color: ImmichColor.primary,
|
||||||
|
variant: ImmichVariant.ghost,
|
||||||
|
onTap: () async {
|
||||||
final croppedImage = await cropController.croppedImage();
|
final croppedImage = await cropController.croppedImage();
|
||||||
unawaited(context.pushRoute(DriftEditImageRoute(asset: asset, image: croppedImage, isEdited: true)));
|
unawaited(context.pushRoute(DriftEditImageRoute(asset: asset, image: croppedImage, isEdited: true)));
|
||||||
},
|
},
|
||||||
@@ -72,17 +75,17 @@ class DriftCropImagePage extends HookWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
ImmichIconButton(
|
||||||
icon: Icon(Icons.rotate_left, color: context.themeData.iconTheme.color),
|
icon: Icons.rotate_left,
|
||||||
onPressed: () {
|
variant: ImmichVariant.ghost,
|
||||||
cropController.rotateLeft();
|
color: ImmichColor.secondary,
|
||||||
},
|
onTap: () => cropController.rotateLeft(),
|
||||||
),
|
),
|
||||||
IconButton(
|
ImmichIconButton(
|
||||||
icon: Icon(Icons.rotate_right, color: context.themeData.iconTheme.color),
|
icon: Icons.rotate_right,
|
||||||
onPressed: () {
|
variant: ImmichVariant.ghost,
|
||||||
cropController.rotateRight();
|
color: ImmichColor.secondary,
|
||||||
},
|
onTap: () => cropController.rotateRight(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -21,12 +21,36 @@ import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_shee
|
|||||||
|
|
||||||
enum AddToMenuItem { album, archive, unarchive, lockedFolder }
|
enum AddToMenuItem { album, archive, unarchive, lockedFolder }
|
||||||
|
|
||||||
class AddActionButton extends ConsumerWidget {
|
class AddActionButton extends ConsumerStatefulWidget {
|
||||||
const AddActionButton({super.key});
|
const AddActionButton({super.key, this.originalTheme});
|
||||||
|
|
||||||
Future<void> _showAddOptions(BuildContext context, WidgetRef ref) async {
|
final ThemeData? originalTheme;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<AddActionButton> createState() => _AddActionButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddActionButtonState extends ConsumerState<AddActionButton> {
|
||||||
|
void _handleMenuSelection(AddToMenuItem selected) {
|
||||||
|
switch (selected) {
|
||||||
|
case AddToMenuItem.album:
|
||||||
|
_openAlbumSelector();
|
||||||
|
break;
|
||||||
|
case AddToMenuItem.archive:
|
||||||
|
performArchiveAction(context, ref, source: ActionSource.viewer);
|
||||||
|
break;
|
||||||
|
case AddToMenuItem.unarchive:
|
||||||
|
performUnArchiveAction(context, ref, source: ActionSource.viewer);
|
||||||
|
break;
|
||||||
|
case AddToMenuItem.lockedFolder:
|
||||||
|
performMoveToLockFolderAction(context, ref, source: ActionSource.viewer);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _buildMenuChildren() {
|
||||||
final asset = ref.read(currentAssetNotifier);
|
final asset = ref.read(currentAssetNotifier);
|
||||||
if (asset == null) return;
|
if (asset == null) return [];
|
||||||
|
|
||||||
final user = ref.read(currentUserProvider);
|
final user = ref.read(currentUserProvider);
|
||||||
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
|
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
|
||||||
@@ -35,93 +59,57 @@ class AddActionButton extends ConsumerWidget {
|
|||||||
final hasRemote = asset is RemoteAsset;
|
final hasRemote = asset is RemoteAsset;
|
||||||
final showArchive = isOwner && !isInLockedView && hasRemote && !isArchived;
|
final showArchive = isOwner && !isInLockedView && hasRemote && !isArchived;
|
||||||
final showUnarchive = isOwner && !isInLockedView && hasRemote && isArchived;
|
final showUnarchive = isOwner && !isInLockedView && hasRemote && isArchived;
|
||||||
final menuItemHeight = 30.0;
|
|
||||||
|
|
||||||
final List<PopupMenuEntry<AddToMenuItem>> items = [
|
return [
|
||||||
PopupMenuItem(
|
Padding(
|
||||||
enabled: false,
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
textStyle: context.textTheme.labelMedium,
|
child: Text("add_to_bottom_bar".tr(), style: context.textTheme.labelMedium),
|
||||||
height: 40,
|
|
||||||
child: Text("add_to_bottom_bar".tr()),
|
|
||||||
),
|
),
|
||||||
PopupMenuItem(
|
BaseActionButton(
|
||||||
height: menuItemHeight,
|
iconData: Icons.photo_album_outlined,
|
||||||
value: AddToMenuItem.album,
|
label: "album".tr(),
|
||||||
child: ListTile(leading: const Icon(Icons.photo_album_outlined), title: Text("album".tr())),
|
menuItem: true,
|
||||||
|
onPressed: () => _handleMenuSelection(AddToMenuItem.album),
|
||||||
),
|
),
|
||||||
const PopupMenuDivider(),
|
|
||||||
PopupMenuItem(enabled: false, textStyle: context.textTheme.labelMedium, height: 40, child: Text("move_to".tr())),
|
|
||||||
if (isOwner) ...[
|
if (isOwner) ...[
|
||||||
|
const Divider(),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
child: Text("move_to".tr(), style: context.textTheme.labelMedium),
|
||||||
|
),
|
||||||
if (showArchive)
|
if (showArchive)
|
||||||
PopupMenuItem(
|
BaseActionButton(
|
||||||
height: menuItemHeight,
|
iconData: Icons.archive_outlined,
|
||||||
value: AddToMenuItem.archive,
|
label: "archive".tr(),
|
||||||
child: ListTile(leading: const Icon(Icons.archive_outlined), title: Text("archive".tr())),
|
menuItem: true,
|
||||||
|
onPressed: () => _handleMenuSelection(AddToMenuItem.archive),
|
||||||
),
|
),
|
||||||
if (showUnarchive)
|
if (showUnarchive)
|
||||||
PopupMenuItem(
|
BaseActionButton(
|
||||||
height: menuItemHeight,
|
iconData: Icons.unarchive_outlined,
|
||||||
value: AddToMenuItem.unarchive,
|
label: "unarchive".tr(),
|
||||||
child: ListTile(leading: const Icon(Icons.unarchive_outlined), title: Text("unarchive".tr())),
|
menuItem: true,
|
||||||
|
onPressed: () => _handleMenuSelection(AddToMenuItem.unarchive),
|
||||||
),
|
),
|
||||||
PopupMenuItem(
|
BaseActionButton(
|
||||||
height: menuItemHeight,
|
iconData: Icons.lock_outline,
|
||||||
value: AddToMenuItem.lockedFolder,
|
label: "locked_folder".tr(),
|
||||||
child: ListTile(leading: const Icon(Icons.lock_outline), title: Text("locked_folder".tr())),
|
menuItem: true,
|
||||||
|
onPressed: () => _handleMenuSelection(AddToMenuItem.lockedFolder),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
final AddToMenuItem? selected = await showMenu<AddToMenuItem>(
|
|
||||||
context: context,
|
|
||||||
color: context.themeData.scaffoldBackgroundColor,
|
|
||||||
position: _menuPosition(context),
|
|
||||||
items: items,
|
|
||||||
popUpAnimationStyle: AnimationStyle.noAnimation,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (selected == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (selected) {
|
|
||||||
case AddToMenuItem.album:
|
|
||||||
_openAlbumSelector(context, ref);
|
|
||||||
break;
|
|
||||||
case AddToMenuItem.archive:
|
|
||||||
await performArchiveAction(context, ref, source: ActionSource.viewer);
|
|
||||||
break;
|
|
||||||
case AddToMenuItem.unarchive:
|
|
||||||
await performUnArchiveAction(context, ref, source: ActionSource.viewer);
|
|
||||||
break;
|
|
||||||
case AddToMenuItem.lockedFolder:
|
|
||||||
await performMoveToLockFolderAction(context, ref, source: ActionSource.viewer);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
RelativeRect _menuPosition(BuildContext context) {
|
void _openAlbumSelector() {
|
||||||
final renderObject = context.findRenderObject();
|
|
||||||
if (renderObject is! RenderBox) {
|
|
||||||
return RelativeRect.fill;
|
|
||||||
}
|
|
||||||
|
|
||||||
final size = renderObject.size;
|
|
||||||
final position = renderObject.localToGlobal(Offset.zero);
|
|
||||||
|
|
||||||
return RelativeRect.fromLTRB(position.dx, position.dy - size.height - 200, position.dx + size.width, position.dy);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _openAlbumSelector(BuildContext context, WidgetRef ref) {
|
|
||||||
final currentAsset = ref.read(currentAssetNotifier);
|
final currentAsset = ref.read(currentAssetNotifier);
|
||||||
if (currentAsset == null) {
|
if (currentAsset == null) {
|
||||||
ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error);
|
ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final List<Widget> slivers = [
|
final List<Widget> slivers = [AlbumSelector(onAlbumSelected: (album) => _addCurrentAssetToAlbum(album))];
|
||||||
AlbumSelector(onAlbumSelected: (album) => _addCurrentAssetToAlbum(context, ref, album)),
|
|
||||||
];
|
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -141,7 +129,7 @@ class AddActionButton extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _addCurrentAssetToAlbum(BuildContext context, WidgetRef ref, RemoteAlbum album) async {
|
Future<void> _addCurrentAssetToAlbum(RemoteAlbum album) async {
|
||||||
final latest = ref.read(currentAssetNotifier);
|
final latest = ref.read(currentAssetNotifier);
|
||||||
|
|
||||||
if (latest == null) {
|
if (latest == null) {
|
||||||
@@ -174,17 +162,38 @@ class AddActionButton extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context) {
|
||||||
final asset = ref.watch(currentAssetNotifier);
|
final asset = ref.watch(currentAssetNotifier);
|
||||||
if (asset == null) {
|
if (asset == null) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
return Builder(
|
|
||||||
builder: (buttonContext) {
|
final themeData = widget.originalTheme ?? context.themeData;
|
||||||
|
|
||||||
|
return MenuAnchor(
|
||||||
|
consumeOutsideTap: true,
|
||||||
|
style: MenuStyle(
|
||||||
|
backgroundColor: WidgetStatePropertyAll(themeData.scaffoldBackgroundColor),
|
||||||
|
surfaceTintColor: const WidgetStatePropertyAll(Colors.grey),
|
||||||
|
elevation: const WidgetStatePropertyAll(4),
|
||||||
|
shape: const WidgetStatePropertyAll(
|
||||||
|
RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))),
|
||||||
|
),
|
||||||
|
padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(vertical: 6)),
|
||||||
|
),
|
||||||
|
menuChildren: widget.originalTheme != null
|
||||||
|
? [
|
||||||
|
Theme(
|
||||||
|
data: widget.originalTheme!,
|
||||||
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: _buildMenuChildren()),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: _buildMenuChildren(),
|
||||||
|
builder: (context, controller, child) {
|
||||||
return BaseActionButton(
|
return BaseActionButton(
|
||||||
iconData: Icons.add,
|
iconData: Icons.add,
|
||||||
label: "add_to_bottom_bar".tr(),
|
label: "add_to_bottom_bar".tr(),
|
||||||
onPressed: () => _showAddOptions(buttonContext, ref),
|
onPressed: () => controller.isOpen ? controller.close() : controller.open(),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
|
||||||
class BaseActionButton extends StatelessWidget {
|
class BaseActionButton extends ConsumerWidget {
|
||||||
const BaseActionButton({
|
const BaseActionButton({
|
||||||
super.key,
|
super.key,
|
||||||
required this.label,
|
required this.label,
|
||||||
@@ -11,6 +12,7 @@ class BaseActionButton extends StatelessWidget {
|
|||||||
this.onLongPressed,
|
this.onLongPressed,
|
||||||
this.maxWidth = 90.0,
|
this.maxWidth = 90.0,
|
||||||
this.minWidth,
|
this.minWidth,
|
||||||
|
this.iconOnly = false,
|
||||||
this.menuItem = false,
|
this.menuItem = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -19,25 +21,42 @@ class BaseActionButton extends StatelessWidget {
|
|||||||
final Color? iconColor;
|
final Color? iconColor;
|
||||||
final double maxWidth;
|
final double maxWidth;
|
||||||
final double? minWidth;
|
final double? minWidth;
|
||||||
|
|
||||||
|
/// When true, renders only an IconButton without text label
|
||||||
|
final bool iconOnly;
|
||||||
|
|
||||||
|
/// When true, renders as a MenuItemButton for use in MenuAnchor menus
|
||||||
final bool menuItem;
|
final bool menuItem;
|
||||||
final void Function()? onPressed;
|
final void Function()? onPressed;
|
||||||
final void Function()? onLongPressed;
|
final void Function()? onLongPressed;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final miniWidth = minWidth ?? (context.isMobile ? context.width / 4.5 : 75.0);
|
final miniWidth = minWidth ?? (context.isMobile ? context.width / 4.5 : 75.0);
|
||||||
final iconTheme = IconTheme.of(context);
|
final iconTheme = IconTheme.of(context);
|
||||||
final iconSize = iconTheme.size ?? 24.0;
|
final iconSize = iconTheme.size ?? 24.0;
|
||||||
final iconColor = this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color;
|
final iconColor = this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color;
|
||||||
final textColor = context.themeData.textTheme.labelLarge?.color;
|
final textColor = context.themeData.textTheme.labelLarge?.color;
|
||||||
|
|
||||||
if (menuItem) {
|
if (iconOnly) {
|
||||||
return IconButton(
|
return IconButton(
|
||||||
onPressed: onPressed,
|
onPressed: onPressed,
|
||||||
icon: Icon(iconData, size: iconSize, color: iconColor),
|
icon: Icon(iconData, size: iconSize, color: iconColor),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (menuItem) {
|
||||||
|
final theme = context.themeData;
|
||||||
|
final effectiveIconColor = iconColor ?? theme.iconTheme.color ?? theme.colorScheme.onSurfaceVariant;
|
||||||
|
|
||||||
|
return MenuItemButton(
|
||||||
|
style: MenuItemButton.styleFrom(alignment: Alignment.centerLeft, padding: const EdgeInsets.all(16)),
|
||||||
|
leadingIcon: Icon(iconData, color: effectiveIconColor),
|
||||||
|
onPressed: onPressed,
|
||||||
|
child: Text(label, style: theme.textTheme.labelLarge?.copyWith(fontSize: 16)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return ConstrainedBox(
|
return ConstrainedBox(
|
||||||
constraints: BoxConstraints(maxWidth: maxWidth),
|
constraints: BoxConstraints(maxWidth: maxWidth),
|
||||||
child: MaterialButton(
|
child: MaterialButton(
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import 'package:immich_mobile/providers/cast.provider.dart';
|
|||||||
import 'package:immich_mobile/widgets/asset_viewer/cast_dialog.dart';
|
import 'package:immich_mobile/widgets/asset_viewer/cast_dialog.dart';
|
||||||
|
|
||||||
class CastActionButton extends ConsumerWidget {
|
class CastActionButton extends ConsumerWidget {
|
||||||
const CastActionButton({super.key, this.menuItem = true});
|
const CastActionButton({super.key, this.iconOnly = false, this.menuItem = false});
|
||||||
|
|
||||||
|
final bool iconOnly;
|
||||||
final bool menuItem;
|
final bool menuItem;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -22,6 +23,7 @@ class CastActionButton extends ConsumerWidget {
|
|||||||
onPressed: () {
|
onPressed: () {
|
||||||
showDialog(context: context, builder: (context) => const CastDialog());
|
showDialog(context: context, builder: (context) => const CastDialog());
|
||||||
},
|
},
|
||||||
|
iconOnly: iconOnly,
|
||||||
menuItem: menuItem,
|
menuItem: menuItem,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
|||||||
|
|
||||||
class DownloadActionButton extends ConsumerWidget {
|
class DownloadActionButton extends ConsumerWidget {
|
||||||
final ActionSource source;
|
final ActionSource source;
|
||||||
|
final bool iconOnly;
|
||||||
final bool menuItem;
|
final bool menuItem;
|
||||||
const DownloadActionButton({super.key, required this.source, this.menuItem = false});
|
const DownloadActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
||||||
|
|
||||||
void _onTap(BuildContext context, WidgetRef ref, BackgroundSyncManager backgroundSyncManager) async {
|
void _onTap(BuildContext context, WidgetRef ref, BackgroundSyncManager backgroundSyncManager) async {
|
||||||
if (!context.mounted) {
|
if (!context.mounted) {
|
||||||
@@ -38,6 +39,7 @@ class DownloadActionButton extends ConsumerWidget {
|
|||||||
iconData: Icons.download,
|
iconData: Icons.download,
|
||||||
maxWidth: 95,
|
maxWidth: 95,
|
||||||
label: "download".t(context: context),
|
label: "download".t(context: context),
|
||||||
|
iconOnly: iconOnly,
|
||||||
menuItem: menuItem,
|
menuItem: menuItem,
|
||||||
onPressed: () => _onTap(context, ref, backgroundManager),
|
onPressed: () => _onTap(context, ref, backgroundManager),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
|||||||
|
|
||||||
class FavoriteActionButton extends ConsumerWidget {
|
class FavoriteActionButton extends ConsumerWidget {
|
||||||
final ActionSource source;
|
final ActionSource source;
|
||||||
|
final bool iconOnly;
|
||||||
final bool menuItem;
|
final bool menuItem;
|
||||||
|
|
||||||
const FavoriteActionButton({super.key, required this.source, this.menuItem = false});
|
const FavoriteActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
||||||
|
|
||||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||||
if (!context.mounted) {
|
if (!context.mounted) {
|
||||||
@@ -44,6 +45,7 @@ class FavoriteActionButton extends ConsumerWidget {
|
|||||||
return BaseActionButton(
|
return BaseActionButton(
|
||||||
iconData: Icons.favorite_border_rounded,
|
iconData: Icons.favorite_border_rounded,
|
||||||
label: "favorite".t(context: context),
|
label: "favorite".t(context: context),
|
||||||
|
iconOnly: iconOnly,
|
||||||
menuItem: menuItem,
|
menuItem: menuItem,
|
||||||
onPressed: () => _onTap(context, ref),
|
onPressed: () => _onTap(context, ref),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ import 'package:immich_mobile/providers/infrastructure/current_album.provider.da
|
|||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
|
|
||||||
class LikeActivityActionButton extends ConsumerWidget {
|
class LikeActivityActionButton extends ConsumerWidget {
|
||||||
const LikeActivityActionButton({super.key, this.menuItem = false});
|
const LikeActivityActionButton({super.key, this.iconOnly = false, this.menuItem = false});
|
||||||
|
|
||||||
|
final bool iconOnly;
|
||||||
final bool menuItem;
|
final bool menuItem;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -49,6 +50,7 @@ class LikeActivityActionButton extends ConsumerWidget {
|
|||||||
iconData: liked != null ? Icons.favorite : Icons.favorite_border,
|
iconData: liked != null ? Icons.favorite : Icons.favorite_border,
|
||||||
label: "like".t(context: context),
|
label: "like".t(context: context),
|
||||||
onPressed: () => onTap(liked),
|
onPressed: () => onTap(liked),
|
||||||
|
iconOnly: iconOnly,
|
||||||
menuItem: menuItem,
|
menuItem: menuItem,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -57,6 +59,7 @@ class LikeActivityActionButton extends ConsumerWidget {
|
|||||||
loading: () => BaseActionButton(
|
loading: () => BaseActionButton(
|
||||||
iconData: Icons.favorite_border,
|
iconData: Icons.favorite_border,
|
||||||
label: "like".t(context: context),
|
label: "like".t(context: context),
|
||||||
|
iconOnly: iconOnly,
|
||||||
menuItem: menuItem,
|
menuItem: menuItem,
|
||||||
),
|
),
|
||||||
error: (error, stack) => Text('error_saving_image'.tr(args: [error.toString()])),
|
error: (error, stack) => Text('error_saving_image'.tr(args: [error.toString()])),
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_bu
|
|||||||
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
||||||
|
|
||||||
class MotionPhotoActionButton extends ConsumerWidget {
|
class MotionPhotoActionButton extends ConsumerWidget {
|
||||||
const MotionPhotoActionButton({super.key, this.menuItem = true});
|
const MotionPhotoActionButton({super.key, this.iconOnly = false, this.menuItem = false});
|
||||||
|
|
||||||
|
final bool iconOnly;
|
||||||
final bool menuItem;
|
final bool menuItem;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -17,6 +18,7 @@ class MotionPhotoActionButton extends ConsumerWidget {
|
|||||||
iconData: isPlaying ? Icons.motion_photos_pause_outlined : Icons.play_circle_outline_rounded,
|
iconData: isPlaying ? Icons.motion_photos_pause_outlined : Icons.play_circle_outline_rounded,
|
||||||
label: "play_motion_photo".t(context: context),
|
label: "play_motion_photo".t(context: context),
|
||||||
onPressed: ref.read(isPlayingMotionVideoProvider.notifier).toggle,
|
onPressed: ref.read(isPlayingMotionVideoProvider.notifier).toggle,
|
||||||
|
iconOnly: iconOnly,
|
||||||
menuItem: menuItem,
|
menuItem: menuItem,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
|||||||
|
|
||||||
class UnFavoriteActionButton extends ConsumerWidget {
|
class UnFavoriteActionButton extends ConsumerWidget {
|
||||||
final ActionSource source;
|
final ActionSource source;
|
||||||
|
final bool iconOnly;
|
||||||
final bool menuItem;
|
final bool menuItem;
|
||||||
|
|
||||||
const UnFavoriteActionButton({super.key, required this.source, this.menuItem = false});
|
const UnFavoriteActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
||||||
|
|
||||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||||
if (!context.mounted) {
|
if (!context.mounted) {
|
||||||
@@ -45,6 +46,7 @@ class UnFavoriteActionButton extends ConsumerWidget {
|
|||||||
iconData: Icons.favorite_rounded,
|
iconData: Icons.favorite_rounded,
|
||||||
label: "unfavorite".t(context: context),
|
label: "unfavorite".t(context: context),
|
||||||
onPressed: () => _onTap(context, ref),
|
onPressed: () => _onTap(context, ref),
|
||||||
|
iconOnly: iconOnly,
|
||||||
menuItem: menuItem,
|
menuItem: menuItem,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,11 +38,13 @@ class ViewerBottomBar extends ConsumerWidget {
|
|||||||
opacity = 0;
|
opacity = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final originalTheme = context.themeData;
|
||||||
|
|
||||||
final actions = <Widget>[
|
final actions = <Widget>[
|
||||||
const ShareActionButton(source: ActionSource.viewer),
|
const ShareActionButton(source: ActionSource.viewer),
|
||||||
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
|
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
|
||||||
if (asset.type == AssetType.image) const EditImageActionButton(),
|
if (asset.type == AssetType.image) const EditImageActionButton(),
|
||||||
if (asset.hasRemote) const AddActionButton(),
|
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
|
||||||
|
|
||||||
if (isOwner) ...[
|
if (isOwner) ...[
|
||||||
asset.isLocalOnly
|
asset.isLocalOnly
|
||||||
|
|||||||
@@ -4,25 +4,19 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:immich_mobile/constants/enums.dart';
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
|
||||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/cast_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart';
|
||||||
import 'package:immich_mobile/providers/activity.provider.dart';
|
import 'package:immich_mobile/providers/activity.provider.dart';
|
||||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
|
||||||
|
|
||||||
class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||||
const ViewerTopAppBar({super.key});
|
const ViewerTopAppBar({super.key});
|
||||||
@@ -41,15 +35,6 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
|||||||
final isInLockedView = ref.watch(inLockedViewProvider);
|
final isInLockedView = ref.watch(inLockedViewProvider);
|
||||||
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
|
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
|
||||||
|
|
||||||
final timelineOrigin = ref.read(timelineServiceProvider).origin;
|
|
||||||
final showViewInTimelineButton =
|
|
||||||
timelineOrigin != TimelineOrigin.main &&
|
|
||||||
timelineOrigin != TimelineOrigin.deepLink &&
|
|
||||||
timelineOrigin != TimelineOrigin.trash &&
|
|
||||||
timelineOrigin != TimelineOrigin.archive &&
|
|
||||||
timelineOrigin != TimelineOrigin.localAlbum &&
|
|
||||||
isOwner;
|
|
||||||
|
|
||||||
final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet));
|
final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet));
|
||||||
int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
|
int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
|
||||||
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
||||||
@@ -62,11 +47,10 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
|||||||
opacity = 0;
|
opacity = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
|
final originalTheme = context.themeData;
|
||||||
|
|
||||||
final actions = <Widget>[
|
final actions = <Widget>[
|
||||||
if (asset.isRemoteOnly) const DownloadActionButton(source: ActionSource.viewer, menuItem: true),
|
if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true),
|
||||||
if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true),
|
|
||||||
if (album != null && album.isActivityEnabled && album.isShared)
|
if (album != null && album.isActivityEnabled && album.isShared)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.chat_outlined),
|
icon: const Icon(Icons.chat_outlined),
|
||||||
@@ -74,28 +58,16 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
|||||||
EventStream.shared.emit(const ViewerOpenBottomSheetEvent(activitiesMode: true));
|
EventStream.shared.emit(const ViewerOpenBottomSheetEvent(activitiesMode: true));
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
if (showViewInTimelineButton)
|
|
||||||
IconButton(
|
|
||||||
onPressed: () async {
|
|
||||||
await context.maybePop();
|
|
||||||
await context.navigateTo(const TabShellRoute(children: [MainTimelineRoute()]));
|
|
||||||
EventStream.shared.emit(ScrollToDateEvent(asset.createdAt));
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.image_search),
|
|
||||||
tooltip: 'view_in_timeline'.t(context: context),
|
|
||||||
),
|
|
||||||
if (asset.hasRemote && isOwner && !asset.isFavorite)
|
if (asset.hasRemote && isOwner && !asset.isFavorite)
|
||||||
const FavoriteActionButton(source: ActionSource.viewer, menuItem: true),
|
const FavoriteActionButton(source: ActionSource.viewer, iconOnly: true),
|
||||||
if (asset.hasRemote && isOwner && asset.isFavorite)
|
if (asset.hasRemote && isOwner && asset.isFavorite)
|
||||||
const UnFavoriteActionButton(source: ActionSource.viewer, menuItem: true),
|
const UnFavoriteActionButton(source: ActionSource.viewer, iconOnly: true),
|
||||||
if (asset.isMotionPhoto) const MotionPhotoActionButton(menuItem: true),
|
|
||||||
const _KebabMenu(),
|
ViewerKebabMenu(originalTheme: originalTheme),
|
||||||
];
|
];
|
||||||
|
|
||||||
final lockedViewActions = <Widget>[
|
final lockedViewActions = <Widget>[ViewerKebabMenu(originalTheme: originalTheme)];
|
||||||
if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true),
|
|
||||||
const _KebabMenu(),
|
|
||||||
];
|
|
||||||
|
|
||||||
return IgnorePointer(
|
return IgnorePointer(
|
||||||
ignoring: opacity < 255,
|
ignoring: opacity < 255,
|
||||||
@@ -122,20 +94,6 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
|||||||
Size get preferredSize => const Size.fromHeight(60.0);
|
Size get preferredSize => const Size.fromHeight(60.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _KebabMenu extends ConsumerWidget {
|
|
||||||
const _KebabMenu();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
return IconButton(
|
|
||||||
onPressed: () {
|
|
||||||
EventStream.shared.emit(const ViewerOpenBottomSheetEvent());
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.more_vert_rounded),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _AppBarBackButton extends ConsumerWidget {
|
class _AppBarBackButton extends ConsumerWidget {
|
||||||
const _AppBarBackButton();
|
const _AppBarBackButton();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
|
import 'package:immich_mobile/utils/action_button.utils.dart';
|
||||||
|
|
||||||
|
class ViewerKebabMenu extends ConsumerWidget {
|
||||||
|
const ViewerKebabMenu({super.key, this.originalTheme});
|
||||||
|
|
||||||
|
final ThemeData? originalTheme;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final asset = ref.watch(currentAssetNotifier);
|
||||||
|
if (asset == null) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
final user = ref.watch(currentUserProvider);
|
||||||
|
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
|
||||||
|
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
|
||||||
|
final timelineOrigin = ref.read(timelineServiceProvider).origin;
|
||||||
|
|
||||||
|
final kebabContext = ViewerKebabMenuButtonContext(
|
||||||
|
asset: asset,
|
||||||
|
isOwner: isOwner,
|
||||||
|
isCasting: isCasting,
|
||||||
|
timelineOrigin: timelineOrigin,
|
||||||
|
originalTheme: originalTheme,
|
||||||
|
);
|
||||||
|
|
||||||
|
final menuChildren = ViewerKebabMenuButtonBuilder.build(kebabContext, context, ref);
|
||||||
|
|
||||||
|
return MenuAnchor(
|
||||||
|
consumeOutsideTap: true,
|
||||||
|
style: MenuStyle(
|
||||||
|
backgroundColor: WidgetStatePropertyAll(context.themeData.scaffoldBackgroundColor),
|
||||||
|
surfaceTintColor: const WidgetStatePropertyAll(Colors.grey),
|
||||||
|
elevation: const WidgetStatePropertyAll(4),
|
||||||
|
shape: const WidgetStatePropertyAll(
|
||||||
|
RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))),
|
||||||
|
),
|
||||||
|
padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(vertical: 6)),
|
||||||
|
),
|
||||||
|
menuChildren: [
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(minWidth: 150),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: menuChildren,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
builder: (context, controller, child) {
|
||||||
|
return IconButton(
|
||||||
|
icon: const Icon(Icons.more_vert_rounded),
|
||||||
|
onPressed: () => controller.isOpen ? controller.close() : controller.open(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -324,7 +324,11 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
|||||||
final topPadding = context.padding.top + (widget.appBar == null ? 0 : kToolbarHeight) + 10;
|
final topPadding = context.padding.top + (widget.appBar == null ? 0 : kToolbarHeight) + 10;
|
||||||
|
|
||||||
const scrubberBottomPadding = 100.0;
|
const scrubberBottomPadding = 100.0;
|
||||||
final bottomPadding = context.padding.bottom + (widget.appBar == null ? 0 : scrubberBottomPadding);
|
const bottomSheetOpenModifier = 120.0;
|
||||||
|
final bottomPadding =
|
||||||
|
context.padding.bottom +
|
||||||
|
(widget.appBar == null ? 0 : scrubberBottomPadding) +
|
||||||
|
(isMultiSelectEnabled ? bottomSheetOpenModifier : 0);
|
||||||
|
|
||||||
final grid = CustomScrollView(
|
final grid = CustomScrollView(
|
||||||
primary: true,
|
primary: true,
|
||||||
@@ -347,7 +351,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
|||||||
addRepaintBoundaries: false,
|
addRepaintBoundaries: false,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SliverPadding(padding: EdgeInsets.only(bottom: scrubberBottomPadding)),
|
SliverPadding(padding: EdgeInsets.only(bottom: bottomPadding)),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -78,9 +78,9 @@ import 'package:immich_mobile/pages/search/recently_taken.page.dart';
|
|||||||
import 'package:immich_mobile/pages/search/search.page.dart';
|
import 'package:immich_mobile/pages/search/search.page.dart';
|
||||||
import 'package:immich_mobile/pages/settings/sync_status.page.dart';
|
import 'package:immich_mobile/pages/settings/sync_status.page.dart';
|
||||||
import 'package:immich_mobile/pages/share_intent/share_intent.page.dart';
|
import 'package:immich_mobile/pages/share_intent/share_intent.page.dart';
|
||||||
import 'package:immich_mobile/presentation/pages/dev/feat_in_development.page.dart';
|
|
||||||
import 'package:immich_mobile/presentation/pages/dev/main_timeline.page.dart';
|
import 'package:immich_mobile/presentation/pages/dev/main_timeline.page.dart';
|
||||||
import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart';
|
import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart';
|
||||||
|
import 'package:immich_mobile/presentation/pages/dev/ui_showcase.page.dart';
|
||||||
import 'package:immich_mobile/presentation/pages/download_info.page.dart';
|
import 'package:immich_mobile/presentation/pages/download_info.page.dart';
|
||||||
import 'package:immich_mobile/presentation/pages/drift_activities.page.dart';
|
import 'package:immich_mobile/presentation/pages/drift_activities.page.dart';
|
||||||
import 'package:immich_mobile/presentation/pages/drift_album.page.dart';
|
import 'package:immich_mobile/presentation/pages/drift_album.page.dart';
|
||||||
@@ -167,7 +167,7 @@ class AppRouter extends RootStackRouter {
|
|||||||
AutoRoute(page: LoginRoute.page, guards: [_duplicateGuard]),
|
AutoRoute(page: LoginRoute.page, guards: [_duplicateGuard]),
|
||||||
AutoRoute(page: ChangePasswordRoute.page),
|
AutoRoute(page: ChangePasswordRoute.page),
|
||||||
AutoRoute(page: SearchRoute.page, guards: [_authGuard, _duplicateGuard], maintainState: false),
|
AutoRoute(page: SearchRoute.page, guards: [_authGuard, _duplicateGuard], maintainState: false),
|
||||||
CustomRoute(
|
AutoRoute(
|
||||||
page: TabControllerRoute.page,
|
page: TabControllerRoute.page,
|
||||||
guards: [_authGuard, _duplicateGuard],
|
guards: [_authGuard, _duplicateGuard],
|
||||||
children: [
|
children: [
|
||||||
@@ -176,9 +176,8 @@ class AppRouter extends RootStackRouter {
|
|||||||
AutoRoute(page: LibraryRoute.page, guards: [_authGuard, _duplicateGuard]),
|
AutoRoute(page: LibraryRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||||
AutoRoute(page: AlbumsRoute.page, guards: [_authGuard, _duplicateGuard]),
|
AutoRoute(page: AlbumsRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||||
],
|
],
|
||||||
transitionsBuilder: TransitionsBuilders.fadeIn,
|
|
||||||
),
|
),
|
||||||
CustomRoute(
|
AutoRoute(
|
||||||
page: TabShellRoute.page,
|
page: TabShellRoute.page,
|
||||||
guards: [_authGuard, _duplicateGuard],
|
guards: [_authGuard, _duplicateGuard],
|
||||||
children: [
|
children: [
|
||||||
@@ -187,7 +186,6 @@ class AppRouter extends RootStackRouter {
|
|||||||
AutoRoute(page: DriftLibraryRoute.page, guards: [_authGuard, _duplicateGuard]),
|
AutoRoute(page: DriftLibraryRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||||
AutoRoute(page: DriftAlbumsRoute.page, guards: [_authGuard, _duplicateGuard]),
|
AutoRoute(page: DriftAlbumsRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||||
],
|
],
|
||||||
transitionsBuilder: TransitionsBuilders.fadeIn,
|
|
||||||
),
|
),
|
||||||
CustomRoute(
|
CustomRoute(
|
||||||
page: GalleryViewerRoute.page,
|
page: GalleryViewerRoute.page,
|
||||||
@@ -288,7 +286,6 @@ class AppRouter extends RootStackRouter {
|
|||||||
AutoRoute(page: ShareIntentRoute.page, guards: [_authGuard, _duplicateGuard]),
|
AutoRoute(page: ShareIntentRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||||
AutoRoute(page: LockedRoute.page, guards: [_authGuard, _lockedGuard, _duplicateGuard]),
|
AutoRoute(page: LockedRoute.page, guards: [_authGuard, _lockedGuard, _duplicateGuard]),
|
||||||
AutoRoute(page: PinAuthRoute.page, guards: [_authGuard, _duplicateGuard]),
|
AutoRoute(page: PinAuthRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||||
AutoRoute(page: FeatInDevRoute.page, guards: [_authGuard, _duplicateGuard]),
|
|
||||||
AutoRoute(page: LocalMediaSummaryRoute.page, guards: [_authGuard, _duplicateGuard]),
|
AutoRoute(page: LocalMediaSummaryRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||||
AutoRoute(page: RemoteMediaSummaryRoute.page, guards: [_authGuard, _duplicateGuard]),
|
AutoRoute(page: RemoteMediaSummaryRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||||
AutoRoute(page: DriftBackupRoute.page, guards: [_authGuard, _duplicateGuard]),
|
AutoRoute(page: DriftBackupRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||||
@@ -340,6 +337,7 @@ class AppRouter extends RootStackRouter {
|
|||||||
AutoRoute(page: DriftBackupAssetDetailRoute.page, guards: [_authGuard, _duplicateGuard]),
|
AutoRoute(page: DriftBackupAssetDetailRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||||
AutoRoute(page: AssetTroubleshootRoute.page, guards: [_authGuard, _duplicateGuard]),
|
AutoRoute(page: AssetTroubleshootRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||||
AutoRoute(page: DownloadInfoRoute.page, guards: [_authGuard, _duplicateGuard]),
|
AutoRoute(page: DownloadInfoRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||||
|
AutoRoute(page: ImmichUIShowcaseRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||||
// required to handle all deeplinks in deep_link.service.dart
|
// required to handle all deeplinks in deep_link.service.dart
|
||||||
// auto_route_library#1722
|
// auto_route_library#1722
|
||||||
RedirectRoute(path: '*', redirectTo: '/'),
|
RedirectRoute(path: '*', redirectTo: '/'),
|
||||||
|
|||||||
@@ -1648,22 +1648,6 @@ class FavoritesRoute extends PageRouteInfo<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// generated route for
|
|
||||||
/// [FeatInDevPage]
|
|
||||||
class FeatInDevRoute extends PageRouteInfo<void> {
|
|
||||||
const FeatInDevRoute({List<PageRouteInfo>? children})
|
|
||||||
: super(FeatInDevRoute.name, initialChildren: children);
|
|
||||||
|
|
||||||
static const String name = 'FeatInDevRoute';
|
|
||||||
|
|
||||||
static PageInfo page = PageInfo(
|
|
||||||
name,
|
|
||||||
builder: (data) {
|
|
||||||
return const FeatInDevPage();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [FilterImagePage]
|
/// [FilterImagePage]
|
||||||
class FilterImageRoute extends PageRouteInfo<FilterImageRouteArgs> {
|
class FilterImageRoute extends PageRouteInfo<FilterImageRouteArgs> {
|
||||||
@@ -1831,6 +1815,22 @@ class HeaderSettingsRoute extends PageRouteInfo<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// generated route for
|
||||||
|
/// [ImmichUIShowcasePage]
|
||||||
|
class ImmichUIShowcaseRoute extends PageRouteInfo<void> {
|
||||||
|
const ImmichUIShowcaseRoute({List<PageRouteInfo>? children})
|
||||||
|
: super(ImmichUIShowcaseRoute.name, initialChildren: children);
|
||||||
|
|
||||||
|
static const String name = 'ImmichUIShowcaseRoute';
|
||||||
|
|
||||||
|
static PageInfo page = PageInfo(
|
||||||
|
name,
|
||||||
|
builder: (data) {
|
||||||
|
return const ImmichUIShowcasePage();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [LibraryPage]
|
/// [LibraryPage]
|
||||||
class LibraryRoute extends PageRouteInfo<void> {
|
class LibraryRoute extends PageRouteInfo<void> {
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
import 'package:flutter/widgets.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/enums.dart';
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||||
|
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||||
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/action_buttons/cast_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
|
||||||
@@ -19,6 +28,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_b
|
|||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
|
||||||
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
|
||||||
class ActionButtonContext {
|
class ActionButtonContext {
|
||||||
final BaseAsset asset;
|
final BaseAsset asset;
|
||||||
@@ -164,3 +174,98 @@ class ActionButtonBuilder {
|
|||||||
return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList();
|
return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ViewerKebabMenuButtonContext {
|
||||||
|
final BaseAsset asset;
|
||||||
|
final bool isOwner;
|
||||||
|
final bool isCasting;
|
||||||
|
final TimelineOrigin timelineOrigin;
|
||||||
|
final ThemeData? originalTheme;
|
||||||
|
|
||||||
|
const ViewerKebabMenuButtonContext({
|
||||||
|
required this.asset,
|
||||||
|
required this.isOwner,
|
||||||
|
required this.isCasting,
|
||||||
|
required this.timelineOrigin,
|
||||||
|
this.originalTheme,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ViewerKebabMenuButtonType {
|
||||||
|
openInfo,
|
||||||
|
viewInTimeline,
|
||||||
|
cast,
|
||||||
|
download;
|
||||||
|
|
||||||
|
/// Defines which group each button belongs to.
|
||||||
|
/// Buttons in the same group will be displayed together,
|
||||||
|
/// with dividers separating different groups.
|
||||||
|
int get group => switch (this) {
|
||||||
|
ViewerKebabMenuButtonType.openInfo => 0,
|
||||||
|
ViewerKebabMenuButtonType.viewInTimeline => 1,
|
||||||
|
ViewerKebabMenuButtonType.cast => 1,
|
||||||
|
ViewerKebabMenuButtonType.download => 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
bool shouldShow(ViewerKebabMenuButtonContext context) {
|
||||||
|
return switch (this) {
|
||||||
|
ViewerKebabMenuButtonType.openInfo => true,
|
||||||
|
ViewerKebabMenuButtonType.viewInTimeline =>
|
||||||
|
context.timelineOrigin != TimelineOrigin.main &&
|
||||||
|
context.timelineOrigin != TimelineOrigin.deepLink &&
|
||||||
|
context.timelineOrigin != TimelineOrigin.trash &&
|
||||||
|
context.timelineOrigin != TimelineOrigin.archive &&
|
||||||
|
context.timelineOrigin != TimelineOrigin.localAlbum &&
|
||||||
|
context.isOwner,
|
||||||
|
ViewerKebabMenuButtonType.cast => context.isCasting || context.asset.hasRemote,
|
||||||
|
ViewerKebabMenuButtonType.download => context.asset.isRemoteOnly,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
ConsumerWidget buildButton(ViewerKebabMenuButtonContext context, BuildContext buildContext) {
|
||||||
|
return switch (this) {
|
||||||
|
ViewerKebabMenuButtonType.openInfo => BaseActionButton(
|
||||||
|
label: 'info'.tr(),
|
||||||
|
iconData: Icons.info_outline,
|
||||||
|
iconColor: context.originalTheme?.iconTheme.color,
|
||||||
|
menuItem: true,
|
||||||
|
onPressed: () => EventStream.shared.emit(const ViewerOpenBottomSheetEvent()),
|
||||||
|
),
|
||||||
|
|
||||||
|
ViewerKebabMenuButtonType.viewInTimeline => BaseActionButton(
|
||||||
|
label: 'view_in_timeline'.t(context: buildContext),
|
||||||
|
iconData: Icons.image_search,
|
||||||
|
iconColor: context.originalTheme?.iconTheme.color,
|
||||||
|
menuItem: true,
|
||||||
|
onPressed: () async {
|
||||||
|
await buildContext.maybePop();
|
||||||
|
await buildContext.navigateTo(const TabShellRoute(children: [MainTimelineRoute()]));
|
||||||
|
EventStream.shared.emit(ScrollToDateEvent(context.asset.createdAt));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ViewerKebabMenuButtonType.cast => const CastActionButton(menuItem: true),
|
||||||
|
ViewerKebabMenuButtonType.download => const DownloadActionButton(source: ActionSource.viewer, menuItem: true),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ViewerKebabMenuButtonBuilder {
|
||||||
|
static List<Widget> build(ViewerKebabMenuButtonContext context, BuildContext buildContext, WidgetRef ref) {
|
||||||
|
final visibleButtons = ViewerKebabMenuButtonType.values.where((type) => type.shouldShow(context)).toList();
|
||||||
|
|
||||||
|
if (visibleButtons.isEmpty) return [];
|
||||||
|
|
||||||
|
final List<Widget> result = [];
|
||||||
|
int? lastGroup;
|
||||||
|
|
||||||
|
for (final type in visibleButtons) {
|
||||||
|
if (lastGroup != null && type.group != lastGroup) {
|
||||||
|
result.add(const Divider(height: 1));
|
||||||
|
}
|
||||||
|
result.add(type.buildButton(context, buildContext).build(buildContext, ref));
|
||||||
|
lastGroup = type.group;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,16 +53,18 @@ class ServerUpdateNotification extends HookConsumerWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Expanded(
|
||||||
serverInfoState.versionStatus.message,
|
child: Text(
|
||||||
textAlign: TextAlign.start,
|
serverInfoState.versionStatus.message,
|
||||||
maxLines: 3,
|
textAlign: TextAlign.start,
|
||||||
overflow: TextOverflow.ellipsis,
|
maxLines: 3,
|
||||||
style: context.textTheme.labelLarge,
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: context.textTheme.labelLarge,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (serverInfoState.versionStatus == VersionStatus.serverOutOfDate ||
|
if (serverInfoState.versionStatus == VersionStatus.serverOutOfDate ||
|
||||||
serverInfoState.versionStatus == VersionStatus.clientOutOfDate) ...[
|
serverInfoState.versionStatus == VersionStatus.clientOutOfDate) ...[
|
||||||
const Spacer(),
|
const SizedBox(width: 8),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: openUpdateLink,
|
onPressed: openUpdateLink,
|
||||||
style: TextButton.styleFrom(
|
style: TextButton.styleFrom(
|
||||||
|
|||||||
@@ -155,8 +155,8 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
|||||||
...actions!.map((action) => Padding(padding: const EdgeInsets.only(right: 16), child: action)),
|
...actions!.map((action) => Padding(padding: const EdgeInsets.only(right: 16), child: action)),
|
||||||
if (kDebugMode || kProfileMode)
|
if (kDebugMode || kProfileMode)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.science_rounded),
|
icon: const Icon(Icons.palette_rounded),
|
||||||
onPressed: () => context.pushRoute(const FeatInDevRoute()),
|
onPressed: () => context.pushRoute(const ImmichUIShowcaseRoute()),
|
||||||
),
|
),
|
||||||
if (isCasting)
|
if (isCasting)
|
||||||
Padding(
|
Padding(
|
||||||
|
|||||||
@@ -74,8 +74,8 @@ class ImmichSliverAppBar extends ConsumerWidget {
|
|||||||
...actions!.map((action) => Padding(padding: const EdgeInsets.only(right: 16), child: action)),
|
...actions!.map((action) => Padding(padding: const EdgeInsets.only(right: 16), child: action)),
|
||||||
if ((kDebugMode || kProfileMode) && !isReadonlyModeEnabled)
|
if ((kDebugMode || kProfileMode) && !isReadonlyModeEnabled)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.science_rounded),
|
icon: const Icon(Icons.palette_rounded),
|
||||||
onPressed: () => context.pushRoute(const FeatInDevRoute()),
|
onPressed: () => context.pushRoute(const ImmichUIShowcaseRoute()),
|
||||||
),
|
),
|
||||||
if (showUploadButton && !isReadonlyModeEnabled)
|
if (showUploadButton && !isReadonlyModeEnabled)
|
||||||
const Padding(padding: EdgeInsets.only(right: 20), child: _BackupIndicator()),
|
const Padding(padding: EdgeInsets.only(right: 20), child: _BackupIndicator()),
|
||||||
|
|||||||
@@ -2,26 +2,27 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||||
|
|
||||||
class EntitiyCountTile extends StatelessWidget {
|
class EntityCountTile extends StatelessWidget {
|
||||||
final int count;
|
final int count;
|
||||||
final String label;
|
final String label;
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
|
|
||||||
const EntitiyCountTile({super.key, required this.count, required this.label, required this.icon});
|
const EntityCountTile({super.key, required this.count, required this.label, required this.icon});
|
||||||
|
|
||||||
String zeroPadding(int number, int targetWidth) {
|
String zeroPadding(int number, int targetWidth) {
|
||||||
final numStr = number.toString();
|
final numStr = number.toString();
|
||||||
return numStr.length < targetWidth ? "0" * (targetWidth - numStr.length) : "";
|
return numStr.length < targetWidth ? "0" * (targetWidth - numStr.length) : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
int calculateMaxDigits(double availableWidth) {
|
|
||||||
const double charWidth = 11.0;
|
|
||||||
return (availableWidth / charWidth).floor().clamp(1, 8);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final screenWidth = MediaQuery.of(context).size.width;
|
||||||
|
final availableWidth = (screenWidth - 32 - 8) / 2;
|
||||||
|
const double charWidth = 11.0;
|
||||||
|
final maxDigits = ((availableWidth - 32) / charWidth).floor().clamp(1, 8);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
|
height: double.infinity,
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: context.colorScheme.surfaceContainerLow,
|
color: context.colorScheme.surfaceContainerLow,
|
||||||
@@ -29,7 +30,6 @@ class EntitiyCountTile extends StatelessWidget {
|
|||||||
border: Border.all(width: 0.5, color: context.colorScheme.outline.withAlpha(25)),
|
border: Border.all(width: 0.5, color: context.colorScheme.outline.withAlpha(25)),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
// Icon and Label
|
// Icon and Label
|
||||||
@@ -38,33 +38,30 @@ class EntitiyCountTile extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Icon(icon, color: context.primaryColor),
|
Icon(icon, color: context.primaryColor),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Flexible(
|
||||||
label,
|
child: Text(
|
||||||
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold, fontSize: 16),
|
label,
|
||||||
|
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold, fontSize: 16),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
|
||||||
// Number
|
// Number
|
||||||
LayoutBuilder(
|
const Spacer(),
|
||||||
builder: (context, constraints) {
|
RichText(
|
||||||
final maxDigits = calculateMaxDigits(constraints.maxWidth);
|
text: TextSpan(
|
||||||
return RichText(
|
style: const TextStyle(fontSize: 18, fontFamily: 'OverpassMono', fontWeight: FontWeight.w600),
|
||||||
text: TextSpan(
|
children: [
|
||||||
style: const TextStyle(fontSize: 18, fontFamily: 'OverpassMono', fontWeight: FontWeight.w600),
|
TextSpan(
|
||||||
children: [
|
text: zeroPadding(count, maxDigits),
|
||||||
TextSpan(
|
style: TextStyle(color: context.colorScheme.onSurfaceSecondary.withAlpha(75)),
|
||||||
text: zeroPadding(count, maxDigits),
|
|
||||||
style: TextStyle(color: context.colorScheme.onSurfaceSecondary.withAlpha(75)),
|
|
||||||
),
|
|
||||||
TextSpan(
|
|
||||||
text: count.toString(),
|
|
||||||
style: TextStyle(color: context.primaryColor),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
TextSpan(
|
||||||
},
|
text: count.toString(),
|
||||||
|
style: TextStyle(color: context.primaryColor),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -282,76 +282,87 @@ class _SyncStatsCounts extends ConsumerWidget {
|
|||||||
_SectionHeaderText(text: "assets".t(context: context)),
|
_SectionHeaderText(text: "assets".t(context: context)),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||||
child: Flex(
|
// 1. Wrap in IntrinsicHeight
|
||||||
direction: Axis.horizontal,
|
child: IntrinsicHeight(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
child: Flex(
|
||||||
spacing: 8.0,
|
direction: Axis.horizontal,
|
||||||
children: [
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
Expanded(
|
// 2. Stretch children vertically to fill the IntrinsicHeight
|
||||||
child: EntitiyCountTile(
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
label: "local".t(context: context),
|
spacing: 8.0,
|
||||||
count: localAssetCount,
|
children: [
|
||||||
icon: Icons.smartphone,
|
Expanded(
|
||||||
|
child: EntityCountTile(
|
||||||
|
label: "local".t(context: context),
|
||||||
|
count: localAssetCount,
|
||||||
|
icon: Icons.smartphone,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
Expanded(
|
||||||
Expanded(
|
child: EntityCountTile(
|
||||||
child: EntitiyCountTile(
|
label: "remote".t(context: context),
|
||||||
label: "remote".t(context: context),
|
count: remoteAssetCount,
|
||||||
count: remoteAssetCount,
|
icon: Icons.cloud,
|
||||||
icon: Icons.cloud,
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_SectionHeaderText(text: "albums".t(context: context)),
|
_SectionHeaderText(text: "albums".t(context: context)),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||||
child: Flex(
|
child: IntrinsicHeight(
|
||||||
direction: Axis.horizontal,
|
child: Flex(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
direction: Axis.horizontal,
|
||||||
spacing: 8.0,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.stretch, // Added
|
||||||
Expanded(
|
spacing: 8.0,
|
||||||
child: EntitiyCountTile(
|
children: [
|
||||||
label: "local".t(context: context),
|
Expanded(
|
||||||
count: localAlbumCount,
|
child: EntityCountTile(
|
||||||
icon: Icons.smartphone,
|
label: "local".t(context: context),
|
||||||
|
count: localAlbumCount,
|
||||||
|
icon: Icons.smartphone,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
Expanded(
|
||||||
Expanded(
|
child: EntityCountTile(
|
||||||
child: EntitiyCountTile(
|
label: "remote".t(context: context),
|
||||||
label: "remote".t(context: context),
|
count: remoteAlbumCount,
|
||||||
count: remoteAlbumCount,
|
icon: Icons.cloud,
|
||||||
icon: Icons.cloud,
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_SectionHeaderText(text: "other".t(context: context)),
|
_SectionHeaderText(text: "other".t(context: context)),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||||
child: Flex(
|
child: IntrinsicHeight(
|
||||||
direction: Axis.horizontal,
|
child: Flex(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
direction: Axis.horizontal,
|
||||||
spacing: 8.0,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.stretch, // Added
|
||||||
Expanded(
|
spacing: 8.0,
|
||||||
child: EntitiyCountTile(
|
children: [
|
||||||
label: "memories".t(context: context),
|
Expanded(
|
||||||
count: memoryCount,
|
child: EntityCountTile(
|
||||||
icon: Icons.calendar_today,
|
label: "memories".t(context: context),
|
||||||
|
count: memoryCount,
|
||||||
|
icon: Icons.calendar_today,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
Expanded(
|
||||||
Expanded(
|
child: EntityCountTile(
|
||||||
child: EntitiyCountTile(
|
label: "hashed_assets".t(context: context),
|
||||||
label: "hashed_assets".t(context: context),
|
count: localHashedCount,
|
||||||
count: localHashedCount,
|
icon: Icons.tag,
|
||||||
icon: Icons.tag,
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// To be removed once the experimental feature is stable
|
// To be removed once the experimental feature is stable
|
||||||
@@ -364,26 +375,29 @@ class _SyncStatsCounts extends ConsumerWidget {
|
|||||||
return counts.when(
|
return counts.when(
|
||||||
data: (c) => Padding(
|
data: (c) => Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||||
child: Flex(
|
child: IntrinsicHeight(
|
||||||
direction: Axis.horizontal,
|
child: Flex(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
direction: Axis.horizontal,
|
||||||
spacing: 8.0,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.stretch, // Added
|
||||||
Expanded(
|
spacing: 8.0,
|
||||||
child: EntitiyCountTile(
|
children: [
|
||||||
label: "local".t(context: context),
|
Expanded(
|
||||||
count: c.total,
|
child: EntityCountTile(
|
||||||
icon: Icons.delete_outline,
|
label: "local".t(context: context),
|
||||||
|
count: c.total,
|
||||||
|
icon: Icons.delete_outline,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
Expanded(
|
||||||
Expanded(
|
child: EntityCountTile(
|
||||||
child: EntitiyCountTile(
|
label: "hashed_assets".t(context: context),
|
||||||
label: "hashed_assets".t(context: context),
|
count: c.hashed,
|
||||||
count: c.hashed,
|
icon: Icons.tag,
|
||||||
icon: Icons.tag,
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
loading: () => const CircularProgressIndicator(),
|
loading: () => const CircularProgressIndicator(),
|
||||||
|
|||||||
4
mobile/openapi/README.md
generated
4
mobile/openapi/README.md
generated
@@ -199,7 +199,6 @@ Class | Method | HTTP request | Description
|
|||||||
*PeopleApi* | [**updatePeople**](doc//PeopleApi.md#updatepeople) | **PUT** /people | Update people
|
*PeopleApi* | [**updatePeople**](doc//PeopleApi.md#updatepeople) | **PUT** /people | Update people
|
||||||
*PeopleApi* | [**updatePerson**](doc//PeopleApi.md#updateperson) | **PUT** /people/{id} | Update person
|
*PeopleApi* | [**updatePerson**](doc//PeopleApi.md#updateperson) | **PUT** /people/{id} | Update person
|
||||||
*PluginsApi* | [**getPlugin**](doc//PluginsApi.md#getplugin) | **GET** /plugins/{id} | Retrieve a plugin
|
*PluginsApi* | [**getPlugin**](doc//PluginsApi.md#getplugin) | **GET** /plugins/{id} | Retrieve a plugin
|
||||||
*PluginsApi* | [**getPluginTriggers**](doc//PluginsApi.md#getplugintriggers) | **GET** /plugins/triggers | List all plugin triggers
|
|
||||||
*PluginsApi* | [**getPlugins**](doc//PluginsApi.md#getplugins) | **GET** /plugins | List all plugins
|
*PluginsApi* | [**getPlugins**](doc//PluginsApi.md#getplugins) | **GET** /plugins | List all plugins
|
||||||
*QueuesApi* | [**emptyQueue**](doc//QueuesApi.md#emptyqueue) | **DELETE** /queues/{name}/jobs | Empty a queue
|
*QueuesApi* | [**emptyQueue**](doc//QueuesApi.md#emptyqueue) | **DELETE** /queues/{name}/jobs | Empty a queue
|
||||||
*QueuesApi* | [**getQueue**](doc//QueuesApi.md#getqueue) | **GET** /queues/{name} | Retrieve a queue
|
*QueuesApi* | [**getQueue**](doc//QueuesApi.md#getqueue) | **GET** /queues/{name} | Retrieve a queue
|
||||||
@@ -466,10 +465,9 @@ Class | Method | HTTP request | Description
|
|||||||
- [PinCodeSetupDto](doc//PinCodeSetupDto.md)
|
- [PinCodeSetupDto](doc//PinCodeSetupDto.md)
|
||||||
- [PlacesResponseDto](doc//PlacesResponseDto.md)
|
- [PlacesResponseDto](doc//PlacesResponseDto.md)
|
||||||
- [PluginActionResponseDto](doc//PluginActionResponseDto.md)
|
- [PluginActionResponseDto](doc//PluginActionResponseDto.md)
|
||||||
- [PluginContextType](doc//PluginContextType.md)
|
- [PluginContext](doc//PluginContext.md)
|
||||||
- [PluginFilterResponseDto](doc//PluginFilterResponseDto.md)
|
- [PluginFilterResponseDto](doc//PluginFilterResponseDto.md)
|
||||||
- [PluginResponseDto](doc//PluginResponseDto.md)
|
- [PluginResponseDto](doc//PluginResponseDto.md)
|
||||||
- [PluginTriggerResponseDto](doc//PluginTriggerResponseDto.md)
|
|
||||||
- [PluginTriggerType](doc//PluginTriggerType.md)
|
- [PluginTriggerType](doc//PluginTriggerType.md)
|
||||||
- [PurchaseResponse](doc//PurchaseResponse.md)
|
- [PurchaseResponse](doc//PurchaseResponse.md)
|
||||||
- [PurchaseUpdate](doc//PurchaseUpdate.md)
|
- [PurchaseUpdate](doc//PurchaseUpdate.md)
|
||||||
|
|||||||
3
mobile/openapi/lib/api.dart
generated
3
mobile/openapi/lib/api.dart
generated
@@ -217,10 +217,9 @@ part 'model/pin_code_reset_dto.dart';
|
|||||||
part 'model/pin_code_setup_dto.dart';
|
part 'model/pin_code_setup_dto.dart';
|
||||||
part 'model/places_response_dto.dart';
|
part 'model/places_response_dto.dart';
|
||||||
part 'model/plugin_action_response_dto.dart';
|
part 'model/plugin_action_response_dto.dart';
|
||||||
part 'model/plugin_context_type.dart';
|
part 'model/plugin_context.dart';
|
||||||
part 'model/plugin_filter_response_dto.dart';
|
part 'model/plugin_filter_response_dto.dart';
|
||||||
part 'model/plugin_response_dto.dart';
|
part 'model/plugin_response_dto.dart';
|
||||||
part 'model/plugin_trigger_response_dto.dart';
|
|
||||||
part 'model/plugin_trigger_type.dart';
|
part 'model/plugin_trigger_type.dart';
|
||||||
part 'model/purchase_response.dart';
|
part 'model/purchase_response.dart';
|
||||||
part 'model/purchase_update.dart';
|
part 'model/purchase_update.dart';
|
||||||
|
|||||||
51
mobile/openapi/lib/api/plugins_api.dart
generated
51
mobile/openapi/lib/api/plugins_api.dart
generated
@@ -73,57 +73,6 @@ class PluginsApi {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List all plugin triggers
|
|
||||||
///
|
|
||||||
/// Retrieve a list of all available plugin triggers.
|
|
||||||
///
|
|
||||||
/// Note: This method returns the HTTP [Response].
|
|
||||||
Future<Response> getPluginTriggersWithHttpInfo() async {
|
|
||||||
// ignore: prefer_const_declarations
|
|
||||||
final apiPath = r'/plugins/triggers';
|
|
||||||
|
|
||||||
// ignore: prefer_final_locals
|
|
||||||
Object? postBody;
|
|
||||||
|
|
||||||
final queryParams = <QueryParam>[];
|
|
||||||
final headerParams = <String, String>{};
|
|
||||||
final formParams = <String, String>{};
|
|
||||||
|
|
||||||
const contentTypes = <String>[];
|
|
||||||
|
|
||||||
|
|
||||||
return apiClient.invokeAPI(
|
|
||||||
apiPath,
|
|
||||||
'GET',
|
|
||||||
queryParams,
|
|
||||||
postBody,
|
|
||||||
headerParams,
|
|
||||||
formParams,
|
|
||||||
contentTypes.isEmpty ? null : contentTypes.first,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List all plugin triggers
|
|
||||||
///
|
|
||||||
/// Retrieve a list of all available plugin triggers.
|
|
||||||
Future<List<PluginTriggerResponseDto>?> getPluginTriggers() async {
|
|
||||||
final response = await getPluginTriggersWithHttpInfo();
|
|
||||||
if (response.statusCode >= HttpStatus.badRequest) {
|
|
||||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
|
||||||
}
|
|
||||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
|
||||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
|
||||||
// FormatException when trying to decode an empty string.
|
|
||||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
|
||||||
final responseBody = await _decodeBodyBytes(response);
|
|
||||||
return (await apiClient.deserializeAsync(responseBody, 'List<PluginTriggerResponseDto>') as List)
|
|
||||||
.cast<PluginTriggerResponseDto>()
|
|
||||||
.toList(growable: false);
|
|
||||||
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List all plugins
|
/// List all plugins
|
||||||
///
|
///
|
||||||
/// Retrieve a list of plugins available to the authenticated user.
|
/// Retrieve a list of plugins available to the authenticated user.
|
||||||
|
|||||||
6
mobile/openapi/lib/api_client.dart
generated
6
mobile/openapi/lib/api_client.dart
generated
@@ -482,14 +482,12 @@ class ApiClient {
|
|||||||
return PlacesResponseDto.fromJson(value);
|
return PlacesResponseDto.fromJson(value);
|
||||||
case 'PluginActionResponseDto':
|
case 'PluginActionResponseDto':
|
||||||
return PluginActionResponseDto.fromJson(value);
|
return PluginActionResponseDto.fromJson(value);
|
||||||
case 'PluginContextType':
|
case 'PluginContext':
|
||||||
return PluginContextTypeTypeTransformer().decode(value);
|
return PluginContextTypeTransformer().decode(value);
|
||||||
case 'PluginFilterResponseDto':
|
case 'PluginFilterResponseDto':
|
||||||
return PluginFilterResponseDto.fromJson(value);
|
return PluginFilterResponseDto.fromJson(value);
|
||||||
case 'PluginResponseDto':
|
case 'PluginResponseDto':
|
||||||
return PluginResponseDto.fromJson(value);
|
return PluginResponseDto.fromJson(value);
|
||||||
case 'PluginTriggerResponseDto':
|
|
||||||
return PluginTriggerResponseDto.fromJson(value);
|
|
||||||
case 'PluginTriggerType':
|
case 'PluginTriggerType':
|
||||||
return PluginTriggerTypeTypeTransformer().decode(value);
|
return PluginTriggerTypeTypeTransformer().decode(value);
|
||||||
case 'PurchaseResponse':
|
case 'PurchaseResponse':
|
||||||
|
|||||||
4
mobile/openapi/lib/api_helper.dart
generated
4
mobile/openapi/lib/api_helper.dart
generated
@@ -127,8 +127,8 @@ String parameterToString(dynamic value) {
|
|||||||
if (value is Permission) {
|
if (value is Permission) {
|
||||||
return PermissionTypeTransformer().encode(value).toString();
|
return PermissionTypeTransformer().encode(value).toString();
|
||||||
}
|
}
|
||||||
if (value is PluginContextType) {
|
if (value is PluginContext) {
|
||||||
return PluginContextTypeTypeTransformer().encode(value).toString();
|
return PluginContextTypeTransformer().encode(value).toString();
|
||||||
}
|
}
|
||||||
if (value is PluginTriggerType) {
|
if (value is PluginTriggerType) {
|
||||||
return PluginTriggerTypeTypeTransformer().encode(value).toString();
|
return PluginTriggerTypeTypeTransformer().encode(value).toString();
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class PluginActionResponseDto {
|
|||||||
|
|
||||||
Object? schema;
|
Object? schema;
|
||||||
|
|
||||||
List<PluginContextType> supportedContexts;
|
List<PluginContext> supportedContexts;
|
||||||
|
|
||||||
String title;
|
String title;
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ class PluginActionResponseDto {
|
|||||||
methodName: mapValueOfType<String>(json, r'methodName')!,
|
methodName: mapValueOfType<String>(json, r'methodName')!,
|
||||||
pluginId: mapValueOfType<String>(json, r'pluginId')!,
|
pluginId: mapValueOfType<String>(json, r'pluginId')!,
|
||||||
schema: mapValueOfType<Object>(json, r'schema'),
|
schema: mapValueOfType<Object>(json, r'schema'),
|
||||||
supportedContexts: PluginContextType.listFromJson(json[r'supportedContexts']),
|
supportedContexts: PluginContext.listFromJson(json[r'supportedContexts']),
|
||||||
title: mapValueOfType<String>(json, r'title')!,
|
title: mapValueOfType<String>(json, r'title')!,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,9 @@
|
|||||||
part of openapi.api;
|
part of openapi.api;
|
||||||
|
|
||||||
|
|
||||||
class PluginContextType {
|
class PluginContext {
|
||||||
/// Instantiate a new enum with the provided [value].
|
/// Instantiate a new enum with the provided [value].
|
||||||
const PluginContextType._(this.value);
|
const PluginContext._(this.value);
|
||||||
|
|
||||||
/// The underlying value of this enum member.
|
/// The underlying value of this enum member.
|
||||||
final String value;
|
final String value;
|
||||||
@@ -23,24 +23,24 @@ class PluginContextType {
|
|||||||
|
|
||||||
String toJson() => value;
|
String toJson() => value;
|
||||||
|
|
||||||
static const asset = PluginContextType._(r'asset');
|
static const asset = PluginContext._(r'asset');
|
||||||
static const album = PluginContextType._(r'album');
|
static const album = PluginContext._(r'album');
|
||||||
static const person = PluginContextType._(r'person');
|
static const person = PluginContext._(r'person');
|
||||||
|
|
||||||
/// List of all possible values in this [enum][PluginContextType].
|
/// List of all possible values in this [enum][PluginContext].
|
||||||
static const values = <PluginContextType>[
|
static const values = <PluginContext>[
|
||||||
asset,
|
asset,
|
||||||
album,
|
album,
|
||||||
person,
|
person,
|
||||||
];
|
];
|
||||||
|
|
||||||
static PluginContextType? fromJson(dynamic value) => PluginContextTypeTypeTransformer().decode(value);
|
static PluginContext? fromJson(dynamic value) => PluginContextTypeTransformer().decode(value);
|
||||||
|
|
||||||
static List<PluginContextType> listFromJson(dynamic json, {bool growable = false,}) {
|
static List<PluginContext> listFromJson(dynamic json, {bool growable = false,}) {
|
||||||
final result = <PluginContextType>[];
|
final result = <PluginContext>[];
|
||||||
if (json is List && json.isNotEmpty) {
|
if (json is List && json.isNotEmpty) {
|
||||||
for (final row in json) {
|
for (final row in json) {
|
||||||
final value = PluginContextType.fromJson(row);
|
final value = PluginContext.fromJson(row);
|
||||||
if (value != null) {
|
if (value != null) {
|
||||||
result.add(value);
|
result.add(value);
|
||||||
}
|
}
|
||||||
@@ -50,16 +50,16 @@ class PluginContextType {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Transformation class that can [encode] an instance of [PluginContextType] to String,
|
/// Transformation class that can [encode] an instance of [PluginContext] to String,
|
||||||
/// and [decode] dynamic data back to [PluginContextType].
|
/// and [decode] dynamic data back to [PluginContext].
|
||||||
class PluginContextTypeTypeTransformer {
|
class PluginContextTypeTransformer {
|
||||||
factory PluginContextTypeTypeTransformer() => _instance ??= const PluginContextTypeTypeTransformer._();
|
factory PluginContextTypeTransformer() => _instance ??= const PluginContextTypeTransformer._();
|
||||||
|
|
||||||
const PluginContextTypeTypeTransformer._();
|
const PluginContextTypeTransformer._();
|
||||||
|
|
||||||
String encode(PluginContextType data) => data.value;
|
String encode(PluginContext data) => data.value;
|
||||||
|
|
||||||
/// Decodes a [dynamic value][data] to a PluginContextType.
|
/// Decodes a [dynamic value][data] to a PluginContext.
|
||||||
///
|
///
|
||||||
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
|
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
|
||||||
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
|
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
|
||||||
@@ -67,12 +67,12 @@ class PluginContextTypeTypeTransformer {
|
|||||||
///
|
///
|
||||||
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
|
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
|
||||||
/// and users are still using an old app with the old code.
|
/// and users are still using an old app with the old code.
|
||||||
PluginContextType? decode(dynamic data, {bool allowNull = true}) {
|
PluginContext? decode(dynamic data, {bool allowNull = true}) {
|
||||||
if (data != null) {
|
if (data != null) {
|
||||||
switch (data) {
|
switch (data) {
|
||||||
case r'asset': return PluginContextType.asset;
|
case r'asset': return PluginContext.asset;
|
||||||
case r'album': return PluginContextType.album;
|
case r'album': return PluginContext.album;
|
||||||
case r'person': return PluginContextType.person;
|
case r'person': return PluginContext.person;
|
||||||
default:
|
default:
|
||||||
if (!allowNull) {
|
if (!allowNull) {
|
||||||
throw ArgumentError('Unknown enum value to decode: $data');
|
throw ArgumentError('Unknown enum value to decode: $data');
|
||||||
@@ -82,7 +82,7 @@ class PluginContextTypeTypeTransformer {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Singleton [PluginContextTypeTypeTransformer] instance.
|
/// Singleton [PluginContextTypeTransformer] instance.
|
||||||
static PluginContextTypeTypeTransformer? _instance;
|
static PluginContextTypeTransformer? _instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ class PluginFilterResponseDto {
|
|||||||
|
|
||||||
Object? schema;
|
Object? schema;
|
||||||
|
|
||||||
List<PluginContextType> supportedContexts;
|
List<PluginContext> supportedContexts;
|
||||||
|
|
||||||
String title;
|
String title;
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ class PluginFilterResponseDto {
|
|||||||
methodName: mapValueOfType<String>(json, r'methodName')!,
|
methodName: mapValueOfType<String>(json, r'methodName')!,
|
||||||
pluginId: mapValueOfType<String>(json, r'pluginId')!,
|
pluginId: mapValueOfType<String>(json, r'pluginId')!,
|
||||||
schema: mapValueOfType<Object>(json, r'schema'),
|
schema: mapValueOfType<Object>(json, r'schema'),
|
||||||
supportedContexts: PluginContextType.listFromJson(json[r'supportedContexts']),
|
supportedContexts: PluginContext.listFromJson(json[r'supportedContexts']),
|
||||||
title: mapValueOfType<String>(json, r'title')!,
|
title: mapValueOfType<String>(json, r'title')!,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,107 +0,0 @@
|
|||||||
//
|
|
||||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
|
||||||
//
|
|
||||||
// @dart=2.18
|
|
||||||
|
|
||||||
// ignore_for_file: unused_element, unused_import
|
|
||||||
// ignore_for_file: always_put_required_named_parameters_first
|
|
||||||
// ignore_for_file: constant_identifier_names
|
|
||||||
// ignore_for_file: lines_longer_than_80_chars
|
|
||||||
|
|
||||||
part of openapi.api;
|
|
||||||
|
|
||||||
class PluginTriggerResponseDto {
|
|
||||||
/// Returns a new [PluginTriggerResponseDto] instance.
|
|
||||||
PluginTriggerResponseDto({
|
|
||||||
required this.contextType,
|
|
||||||
required this.type,
|
|
||||||
});
|
|
||||||
|
|
||||||
PluginContextType contextType;
|
|
||||||
|
|
||||||
PluginTriggerType type;
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool operator ==(Object other) => identical(this, other) || other is PluginTriggerResponseDto &&
|
|
||||||
other.contextType == contextType &&
|
|
||||||
other.type == type;
|
|
||||||
|
|
||||||
@override
|
|
||||||
int get hashCode =>
|
|
||||||
// ignore: unnecessary_parenthesis
|
|
||||||
(contextType.hashCode) +
|
|
||||||
(type.hashCode);
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() => 'PluginTriggerResponseDto[contextType=$contextType, type=$type]';
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
|
||||||
final json = <String, dynamic>{};
|
|
||||||
json[r'contextType'] = this.contextType;
|
|
||||||
json[r'type'] = this.type;
|
|
||||||
return json;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns a new [PluginTriggerResponseDto] instance and imports its values from
|
|
||||||
/// [value] if it's a [Map], null otherwise.
|
|
||||||
// ignore: prefer_constructors_over_static_methods
|
|
||||||
static PluginTriggerResponseDto? fromJson(dynamic value) {
|
|
||||||
upgradeDto(value, "PluginTriggerResponseDto");
|
|
||||||
if (value is Map) {
|
|
||||||
final json = value.cast<String, dynamic>();
|
|
||||||
|
|
||||||
return PluginTriggerResponseDto(
|
|
||||||
contextType: PluginContextType.fromJson(json[r'contextType'])!,
|
|
||||||
type: PluginTriggerType.fromJson(json[r'type'])!,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
static List<PluginTriggerResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
|
||||||
final result = <PluginTriggerResponseDto>[];
|
|
||||||
if (json is List && json.isNotEmpty) {
|
|
||||||
for (final row in json) {
|
|
||||||
final value = PluginTriggerResponseDto.fromJson(row);
|
|
||||||
if (value != null) {
|
|
||||||
result.add(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result.toList(growable: growable);
|
|
||||||
}
|
|
||||||
|
|
||||||
static Map<String, PluginTriggerResponseDto> mapFromJson(dynamic json) {
|
|
||||||
final map = <String, PluginTriggerResponseDto>{};
|
|
||||||
if (json is Map && json.isNotEmpty) {
|
|
||||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
|
||||||
for (final entry in json.entries) {
|
|
||||||
final value = PluginTriggerResponseDto.fromJson(entry.value);
|
|
||||||
if (value != null) {
|
|
||||||
map[entry.key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
// maps a json object with a list of PluginTriggerResponseDto-objects as value to a dart map
|
|
||||||
static Map<String, List<PluginTriggerResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
|
||||||
final map = <String, List<PluginTriggerResponseDto>>{};
|
|
||||||
if (json is Map && json.isNotEmpty) {
|
|
||||||
// ignore: parameter_assignments
|
|
||||||
json = json.cast<String, dynamic>();
|
|
||||||
for (final entry in json.entries) {
|
|
||||||
map[entry.key] = PluginTriggerResponseDto.listFromJson(entry.value, growable: growable,);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The list of required keys that must be present in a JSON.
|
|
||||||
static const requiredKeys = <String>{
|
|
||||||
'contextType',
|
|
||||||
'type',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
78
mobile/openapi/lib/model/workflow_response_dto.dart
generated
78
mobile/openapi/lib/model/workflow_response_dto.dart
generated
@@ -40,7 +40,7 @@ class WorkflowResponseDto {
|
|||||||
|
|
||||||
String ownerId;
|
String ownerId;
|
||||||
|
|
||||||
PluginTriggerType triggerType;
|
WorkflowResponseDtoTriggerTypeEnum triggerType;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) => identical(this, other) || other is WorkflowResponseDto &&
|
bool operator ==(Object other) => identical(this, other) || other is WorkflowResponseDto &&
|
||||||
@@ -105,7 +105,7 @@ class WorkflowResponseDto {
|
|||||||
id: mapValueOfType<String>(json, r'id')!,
|
id: mapValueOfType<String>(json, r'id')!,
|
||||||
name: mapValueOfType<String>(json, r'name'),
|
name: mapValueOfType<String>(json, r'name'),
|
||||||
ownerId: mapValueOfType<String>(json, r'ownerId')!,
|
ownerId: mapValueOfType<String>(json, r'ownerId')!,
|
||||||
triggerType: PluginTriggerType.fromJson(json[r'triggerType'])!,
|
triggerType: WorkflowResponseDtoTriggerTypeEnum.fromJson(json[r'triggerType'])!,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -165,3 +165,77 @@ class WorkflowResponseDto {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowResponseDtoTriggerTypeEnum {
|
||||||
|
/// Instantiate a new enum with the provided [value].
|
||||||
|
const WorkflowResponseDtoTriggerTypeEnum._(this.value);
|
||||||
|
|
||||||
|
/// The underlying value of this enum member.
|
||||||
|
final String value;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => value;
|
||||||
|
|
||||||
|
String toJson() => value;
|
||||||
|
|
||||||
|
static const assetCreate = WorkflowResponseDtoTriggerTypeEnum._(r'AssetCreate');
|
||||||
|
static const personRecognized = WorkflowResponseDtoTriggerTypeEnum._(r'PersonRecognized');
|
||||||
|
|
||||||
|
/// List of all possible values in this [enum][WorkflowResponseDtoTriggerTypeEnum].
|
||||||
|
static const values = <WorkflowResponseDtoTriggerTypeEnum>[
|
||||||
|
assetCreate,
|
||||||
|
personRecognized,
|
||||||
|
];
|
||||||
|
|
||||||
|
static WorkflowResponseDtoTriggerTypeEnum? fromJson(dynamic value) => WorkflowResponseDtoTriggerTypeEnumTypeTransformer().decode(value);
|
||||||
|
|
||||||
|
static List<WorkflowResponseDtoTriggerTypeEnum> listFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final result = <WorkflowResponseDtoTriggerTypeEnum>[];
|
||||||
|
if (json is List && json.isNotEmpty) {
|
||||||
|
for (final row in json) {
|
||||||
|
final value = WorkflowResponseDtoTriggerTypeEnum.fromJson(row);
|
||||||
|
if (value != null) {
|
||||||
|
result.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.toList(growable: growable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transformation class that can [encode] an instance of [WorkflowResponseDtoTriggerTypeEnum] to String,
|
||||||
|
/// and [decode] dynamic data back to [WorkflowResponseDtoTriggerTypeEnum].
|
||||||
|
class WorkflowResponseDtoTriggerTypeEnumTypeTransformer {
|
||||||
|
factory WorkflowResponseDtoTriggerTypeEnumTypeTransformer() => _instance ??= const WorkflowResponseDtoTriggerTypeEnumTypeTransformer._();
|
||||||
|
|
||||||
|
const WorkflowResponseDtoTriggerTypeEnumTypeTransformer._();
|
||||||
|
|
||||||
|
String encode(WorkflowResponseDtoTriggerTypeEnum data) => data.value;
|
||||||
|
|
||||||
|
/// Decodes a [dynamic value][data] to a WorkflowResponseDtoTriggerTypeEnum.
|
||||||
|
///
|
||||||
|
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
|
||||||
|
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
|
||||||
|
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
|
||||||
|
///
|
||||||
|
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
|
||||||
|
/// and users are still using an old app with the old code.
|
||||||
|
WorkflowResponseDtoTriggerTypeEnum? decode(dynamic data, {bool allowNull = true}) {
|
||||||
|
if (data != null) {
|
||||||
|
switch (data) {
|
||||||
|
case r'AssetCreate': return WorkflowResponseDtoTriggerTypeEnum.assetCreate;
|
||||||
|
case r'PersonRecognized': return WorkflowResponseDtoTriggerTypeEnum.personRecognized;
|
||||||
|
default:
|
||||||
|
if (!allowNull) {
|
||||||
|
throw ArgumentError('Unknown enum value to decode: $data');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Singleton [WorkflowResponseDtoTriggerTypeEnumTypeTransformer] instance.
|
||||||
|
static WorkflowResponseDtoTriggerTypeEnumTypeTransformer? _instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
23
mobile/openapi/lib/model/workflow_update_dto.dart
generated
23
mobile/openapi/lib/model/workflow_update_dto.dart
generated
@@ -18,7 +18,6 @@ class WorkflowUpdateDto {
|
|||||||
this.enabled,
|
this.enabled,
|
||||||
this.filters = const [],
|
this.filters = const [],
|
||||||
this.name,
|
this.name,
|
||||||
this.triggerType,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
List<WorkflowActionItemDto> actions;
|
List<WorkflowActionItemDto> actions;
|
||||||
@@ -49,22 +48,13 @@ class WorkflowUpdateDto {
|
|||||||
///
|
///
|
||||||
String? name;
|
String? name;
|
||||||
|
|
||||||
///
|
|
||||||
/// Please note: This property should have been non-nullable! Since the specification file
|
|
||||||
/// does not include a default value (using the "default:" property), however, the generated
|
|
||||||
/// source code must fall back to having a nullable type.
|
|
||||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
|
||||||
///
|
|
||||||
PluginTriggerType? triggerType;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) => identical(this, other) || other is WorkflowUpdateDto &&
|
bool operator ==(Object other) => identical(this, other) || other is WorkflowUpdateDto &&
|
||||||
_deepEquality.equals(other.actions, actions) &&
|
_deepEquality.equals(other.actions, actions) &&
|
||||||
other.description == description &&
|
other.description == description &&
|
||||||
other.enabled == enabled &&
|
other.enabled == enabled &&
|
||||||
_deepEquality.equals(other.filters, filters) &&
|
_deepEquality.equals(other.filters, filters) &&
|
||||||
other.name == name &&
|
other.name == name;
|
||||||
other.triggerType == triggerType;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode =>
|
int get hashCode =>
|
||||||
@@ -73,11 +63,10 @@ class WorkflowUpdateDto {
|
|||||||
(description == null ? 0 : description!.hashCode) +
|
(description == null ? 0 : description!.hashCode) +
|
||||||
(enabled == null ? 0 : enabled!.hashCode) +
|
(enabled == null ? 0 : enabled!.hashCode) +
|
||||||
(filters.hashCode) +
|
(filters.hashCode) +
|
||||||
(name == null ? 0 : name!.hashCode) +
|
(name == null ? 0 : name!.hashCode);
|
||||||
(triggerType == null ? 0 : triggerType!.hashCode);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => 'WorkflowUpdateDto[actions=$actions, description=$description, enabled=$enabled, filters=$filters, name=$name, triggerType=$triggerType]';
|
String toString() => 'WorkflowUpdateDto[actions=$actions, description=$description, enabled=$enabled, filters=$filters, name=$name]';
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
final json = <String, dynamic>{};
|
final json = <String, dynamic>{};
|
||||||
@@ -98,11 +87,6 @@ class WorkflowUpdateDto {
|
|||||||
} else {
|
} else {
|
||||||
// json[r'name'] = null;
|
// json[r'name'] = null;
|
||||||
}
|
}
|
||||||
if (this.triggerType != null) {
|
|
||||||
json[r'triggerType'] = this.triggerType;
|
|
||||||
} else {
|
|
||||||
// json[r'triggerType'] = null;
|
|
||||||
}
|
|
||||||
return json;
|
return json;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +104,6 @@ class WorkflowUpdateDto {
|
|||||||
enabled: mapValueOfType<bool>(json, r'enabled'),
|
enabled: mapValueOfType<bool>(json, r'enabled'),
|
||||||
filters: WorkflowFilterItemDto.listFromJson(json[r'filters']),
|
filters: WorkflowFilterItemDto.listFromJson(json[r'filters']),
|
||||||
name: mapValueOfType<String>(json, r'name'),
|
name: mapValueOfType<String>(json, r'name'),
|
||||||
triggerType: PluginTriggerType.fromJson(json[r'triggerType']),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
3
mobile/packages/ui/lib/immich_ui.dart
Normal file
3
mobile/packages/ui/lib/immich_ui.dart
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export 'src/buttons/close_button.dart';
|
||||||
|
export 'src/buttons/icon_button.dart';
|
||||||
|
export 'src/types.dart';
|
||||||
25
mobile/packages/ui/lib/src/buttons/close_button.dart
Normal file
25
mobile/packages/ui/lib/src/buttons/close_button.dart
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:immich_ui/src/buttons/icon_button.dart';
|
||||||
|
import 'package:immich_ui/src/types.dart';
|
||||||
|
|
||||||
|
class ImmichCloseButton extends StatelessWidget {
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
final ImmichVariant variant;
|
||||||
|
final ImmichColor color;
|
||||||
|
|
||||||
|
const ImmichCloseButton({
|
||||||
|
super.key,
|
||||||
|
this.onTap,
|
||||||
|
this.color = ImmichColor.primary,
|
||||||
|
this.variant = ImmichVariant.ghost,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => ImmichIconButton(
|
||||||
|
key: key,
|
||||||
|
icon: Icons.close,
|
||||||
|
color: color,
|
||||||
|
variant: variant,
|
||||||
|
onTap: onTap ?? () => Navigator.of(context).pop(),
|
||||||
|
);
|
||||||
|
}
|
||||||
48
mobile/packages/ui/lib/src/buttons/icon_button.dart
Normal file
48
mobile/packages/ui/lib/src/buttons/icon_button.dart
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:immich_ui/src/types.dart';
|
||||||
|
|
||||||
|
class ImmichIconButton extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final ImmichVariant variant;
|
||||||
|
final ImmichColor color;
|
||||||
|
|
||||||
|
const ImmichIconButton({
|
||||||
|
super.key,
|
||||||
|
required this.icon,
|
||||||
|
required this.onTap,
|
||||||
|
this.color = ImmichColor.primary,
|
||||||
|
this.variant = ImmichVariant.filled,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final background = switch (variant) {
|
||||||
|
ImmichVariant.filled => switch (color) {
|
||||||
|
ImmichColor.primary => Theme.of(context).colorScheme.primary,
|
||||||
|
ImmichColor.secondary => Theme.of(context).colorScheme.secondary,
|
||||||
|
},
|
||||||
|
ImmichVariant.ghost => Colors.transparent,
|
||||||
|
};
|
||||||
|
|
||||||
|
final foreground = switch (variant) {
|
||||||
|
ImmichVariant.filled => switch (color) {
|
||||||
|
ImmichColor.primary => Theme.of(context).colorScheme.onPrimary,
|
||||||
|
ImmichColor.secondary => Theme.of(context).colorScheme.onSecondary,
|
||||||
|
},
|
||||||
|
ImmichVariant.ghost => switch (color) {
|
||||||
|
ImmichColor.primary => Theme.of(context).colorScheme.primary,
|
||||||
|
ImmichColor.secondary => Theme.of(context).colorScheme.secondary,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return IconButton(
|
||||||
|
icon: Icon(icon),
|
||||||
|
onPressed: onTap,
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
backgroundColor: background,
|
||||||
|
foregroundColor: foreground,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
mobile/packages/ui/lib/src/types.dart
Normal file
9
mobile/packages/ui/lib/src/types.dart
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
enum ImmichVariant {
|
||||||
|
filled,
|
||||||
|
ghost,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ImmichColor {
|
||||||
|
primary,
|
||||||
|
secondary,
|
||||||
|
}
|
||||||
55
mobile/packages/ui/pubspec.lock
Normal file
55
mobile/packages/ui/pubspec.lock
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# Generated by pub
|
||||||
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
|
packages:
|
||||||
|
characters:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: characters
|
||||||
|
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.0"
|
||||||
|
collection:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: collection
|
||||||
|
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.19.1"
|
||||||
|
flutter:
|
||||||
|
dependency: "direct main"
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
|
material_color_utilities:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: material_color_utilities
|
||||||
|
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.11.1"
|
||||||
|
meta:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: meta
|
||||||
|
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.16.0"
|
||||||
|
sky_engine:
|
||||||
|
dependency: transitive
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
|
vector_math:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: vector_math
|
||||||
|
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
|
sdks:
|
||||||
|
dart: ">=3.8.0-0 <4.0.0"
|
||||||
12
mobile/packages/ui/pubspec.yaml
Normal file
12
mobile/packages/ui/pubspec.yaml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
name: immich_ui
|
||||||
|
publish_to: none
|
||||||
|
|
||||||
|
environment:
|
||||||
|
sdk: '>=3.0.0 <4.0.0'
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
flutter:
|
||||||
|
sdk: flutter
|
||||||
|
|
||||||
|
flutter:
|
||||||
|
uses-material-design: true
|
||||||
@@ -1015,6 +1015,13 @@ packages:
|
|||||||
relative: true
|
relative: true
|
||||||
source: path
|
source: path
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
immich_ui:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
path: "packages/ui"
|
||||||
|
relative: true
|
||||||
|
source: path
|
||||||
|
version: "0.0.0"
|
||||||
integration_test:
|
integration_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ dependencies:
|
|||||||
hooks_riverpod: ^2.6.1
|
hooks_riverpod: ^2.6.1
|
||||||
http: ^1.5.0
|
http: ^1.5.0
|
||||||
image_picker: ^1.2.0
|
image_picker: ^1.2.0
|
||||||
|
immich_ui:
|
||||||
|
path: './packages/ui'
|
||||||
intl: ^0.20.2
|
intl: ^0.20.2
|
||||||
isar:
|
isar:
|
||||||
git:
|
git:
|
||||||
|
|||||||
@@ -8020,55 +8020,6 @@
|
|||||||
"x-immich-state": "Alpha"
|
"x-immich-state": "Alpha"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/plugins/triggers": {
|
|
||||||
"get": {
|
|
||||||
"description": "Retrieve a list of all available plugin triggers.",
|
|
||||||
"operationId": "getPluginTriggers",
|
|
||||||
"parameters": [],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/components/schemas/PluginTriggerResponseDto"
|
|
||||||
},
|
|
||||||
"type": "array"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"security": [
|
|
||||||
{
|
|
||||||
"bearer": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cookie": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"api_key": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"summary": "List all plugin triggers",
|
|
||||||
"tags": [
|
|
||||||
"Plugins"
|
|
||||||
],
|
|
||||||
"x-immich-history": [
|
|
||||||
{
|
|
||||||
"version": "v2.3.0",
|
|
||||||
"state": "Added"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "v2.3.0",
|
|
||||||
"state": "Alpha"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"x-immich-permission": "plugin.read",
|
|
||||||
"x-immich-state": "Alpha"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/plugins/{id}": {
|
"/plugins/{id}": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Retrieve information about a specific plugin by its ID.",
|
"description": "Retrieve information about a specific plugin by its ID.",
|
||||||
@@ -18331,7 +18282,7 @@
|
|||||||
},
|
},
|
||||||
"supportedContexts": {
|
"supportedContexts": {
|
||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/components/schemas/PluginContextType"
|
"$ref": "#/components/schemas/PluginContext"
|
||||||
},
|
},
|
||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
@@ -18350,7 +18301,7 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
"PluginContextType": {
|
"PluginContext": {
|
||||||
"enum": [
|
"enum": [
|
||||||
"asset",
|
"asset",
|
||||||
"album",
|
"album",
|
||||||
@@ -18378,7 +18329,7 @@
|
|||||||
},
|
},
|
||||||
"supportedContexts": {
|
"supportedContexts": {
|
||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/components/schemas/PluginContextType"
|
"$ref": "#/components/schemas/PluginContext"
|
||||||
},
|
},
|
||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
@@ -18450,29 +18401,6 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
"PluginTriggerResponseDto": {
|
|
||||||
"properties": {
|
|
||||||
"contextType": {
|
|
||||||
"allOf": [
|
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/PluginContextType"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"type": {
|
|
||||||
"allOf": [
|
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/PluginTriggerType"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"contextType",
|
|
||||||
"type"
|
|
||||||
],
|
|
||||||
"type": "object"
|
|
||||||
},
|
|
||||||
"PluginTriggerType": {
|
"PluginTriggerType": {
|
||||||
"enum": [
|
"enum": [
|
||||||
"AssetCreate",
|
"AssetCreate",
|
||||||
@@ -23388,11 +23316,11 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"triggerType": {
|
"triggerType": {
|
||||||
"allOf": [
|
"enum": [
|
||||||
{
|
"AssetCreate",
|
||||||
"$ref": "#/components/schemas/PluginTriggerType"
|
"PersonRecognized"
|
||||||
}
|
],
|
||||||
]
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -23430,13 +23358,6 @@
|
|||||||
},
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
|
||||||
"triggerType": {
|
|
||||||
"allOf": [
|
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/PluginTriggerType"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"type": "object"
|
"type": "object"
|
||||||
|
|||||||
@@ -942,7 +942,7 @@ export type PluginActionResponseDto = {
|
|||||||
methodName: string;
|
methodName: string;
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
schema: object | null;
|
schema: object | null;
|
||||||
supportedContexts: PluginContextType[];
|
supportedContexts: PluginContext[];
|
||||||
title: string;
|
title: string;
|
||||||
};
|
};
|
||||||
export type PluginFilterResponseDto = {
|
export type PluginFilterResponseDto = {
|
||||||
@@ -951,7 +951,7 @@ export type PluginFilterResponseDto = {
|
|||||||
methodName: string;
|
methodName: string;
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
schema: object | null;
|
schema: object | null;
|
||||||
supportedContexts: PluginContextType[];
|
supportedContexts: PluginContext[];
|
||||||
title: string;
|
title: string;
|
||||||
};
|
};
|
||||||
export type PluginResponseDto = {
|
export type PluginResponseDto = {
|
||||||
@@ -966,10 +966,6 @@ export type PluginResponseDto = {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
version: string;
|
version: string;
|
||||||
};
|
};
|
||||||
export type PluginTriggerResponseDto = {
|
|
||||||
contextType: PluginContextType;
|
|
||||||
"type": PluginTriggerType;
|
|
||||||
};
|
|
||||||
export type QueueResponseDto = {
|
export type QueueResponseDto = {
|
||||||
isPaused: boolean;
|
isPaused: boolean;
|
||||||
name: QueueName;
|
name: QueueName;
|
||||||
@@ -1754,7 +1750,7 @@ export type WorkflowResponseDto = {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
triggerType: PluginTriggerType;
|
triggerType: TriggerType;
|
||||||
};
|
};
|
||||||
export type WorkflowActionItemDto = {
|
export type WorkflowActionItemDto = {
|
||||||
actionConfig?: object;
|
actionConfig?: object;
|
||||||
@@ -1778,7 +1774,6 @@ export type WorkflowUpdateDto = {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
filters?: WorkflowFilterItemDto[];
|
filters?: WorkflowFilterItemDto[];
|
||||||
name?: string;
|
name?: string;
|
||||||
triggerType?: PluginTriggerType;
|
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* List all activities
|
* List all activities
|
||||||
@@ -3661,17 +3656,6 @@ export function getPlugins(opts?: Oazapfts.RequestOpts) {
|
|||||||
...opts
|
...opts
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
/**
|
|
||||||
* List all plugin triggers
|
|
||||||
*/
|
|
||||||
export function getPluginTriggers(opts?: Oazapfts.RequestOpts) {
|
|
||||||
return oazapfts.ok(oazapfts.fetchJson<{
|
|
||||||
status: 200;
|
|
||||||
data: PluginTriggerResponseDto[];
|
|
||||||
}>("/plugins/triggers", {
|
|
||||||
...opts
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
/**
|
/**
|
||||||
* Retrieve a plugin
|
* Retrieve a plugin
|
||||||
*/
|
*/
|
||||||
@@ -5434,15 +5418,11 @@ export enum PartnerDirection {
|
|||||||
SharedBy = "shared-by",
|
SharedBy = "shared-by",
|
||||||
SharedWith = "shared-with"
|
SharedWith = "shared-with"
|
||||||
}
|
}
|
||||||
export enum PluginContextType {
|
export enum PluginContext {
|
||||||
Asset = "asset",
|
Asset = "asset",
|
||||||
Album = "album",
|
Album = "album",
|
||||||
Person = "person"
|
Person = "person"
|
||||||
}
|
}
|
||||||
export enum PluginTriggerType {
|
|
||||||
AssetCreate = "AssetCreate",
|
|
||||||
PersonRecognized = "PersonRecognized"
|
|
||||||
}
|
|
||||||
export enum QueueJobStatus {
|
export enum QueueJobStatus {
|
||||||
Active = "active",
|
Active = "active",
|
||||||
Failed = "failed",
|
Failed = "failed",
|
||||||
@@ -5659,3 +5639,11 @@ export enum OAuthTokenEndpointAuthMethod {
|
|||||||
ClientSecretPost = "client_secret_post",
|
ClientSecretPost = "client_secret_post",
|
||||||
ClientSecretBasic = "client_secret_basic"
|
ClientSecretBasic = "client_secret_basic"
|
||||||
}
|
}
|
||||||
|
export enum TriggerType {
|
||||||
|
AssetCreate = "AssetCreate",
|
||||||
|
PersonRecognized = "PersonRecognized"
|
||||||
|
}
|
||||||
|
export enum PluginTriggerType {
|
||||||
|
AssetCreate = "AssetCreate",
|
||||||
|
PersonRecognized = "PersonRecognized"
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,36 +1,30 @@
|
|||||||
{
|
{
|
||||||
"name": "immich-core",
|
"name": "immich-core",
|
||||||
"version": "2.0.1",
|
"version": "2.0.0",
|
||||||
"title": "Immich Core",
|
"title": "Immich Core",
|
||||||
"description": "Core workflow capabilities for Immich",
|
"description": "Core workflow capabilities for Immich",
|
||||||
"author": "Immich Team",
|
"author": "Immich Team",
|
||||||
|
|
||||||
"wasm": {
|
"wasm": {
|
||||||
"path": "dist/plugin.wasm"
|
"path": "dist/plugin.wasm"
|
||||||
},
|
},
|
||||||
|
|
||||||
"filters": [
|
"filters": [
|
||||||
{
|
{
|
||||||
"methodName": "filterFileName",
|
"methodName": "filterFileName",
|
||||||
"title": "Filter by filename",
|
"title": "Filter by filename",
|
||||||
"description": "Filter assets by filename pattern using text matching or regular expressions",
|
"description": "Filter assets by filename pattern using text matching or regular expressions",
|
||||||
"supportedContexts": [
|
"supportedContexts": ["asset"],
|
||||||
"asset"
|
|
||||||
],
|
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"pattern": {
|
"pattern": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"title": "Filename pattern",
|
|
||||||
"description": "Text or regex pattern to match against filename"
|
"description": "Text or regex pattern to match against filename"
|
||||||
},
|
},
|
||||||
"matchType": {
|
"matchType": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"title": "Match type",
|
"enum": ["contains", "regex", "exact"],
|
||||||
"enum": [
|
|
||||||
"contains",
|
|
||||||
"regex",
|
|
||||||
"exact"
|
|
||||||
],
|
|
||||||
"default": "contains",
|
"default": "contains",
|
||||||
"description": "Type of pattern matching to perform"
|
"description": "Type of pattern matching to perform"
|
||||||
},
|
},
|
||||||
@@ -40,57 +34,43 @@
|
|||||||
"description": "Whether matching should be case-sensitive"
|
"description": "Whether matching should be case-sensitive"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": ["pattern"]
|
||||||
"pattern"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"methodName": "filterFileType",
|
"methodName": "filterFileType",
|
||||||
"title": "Filter by file type",
|
"title": "Filter by file type",
|
||||||
"description": "Filter assets by file type",
|
"description": "Filter assets by file type",
|
||||||
"supportedContexts": [
|
"supportedContexts": ["asset"],
|
||||||
"asset"
|
|
||||||
],
|
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"fileTypes": {
|
"fileTypes": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"title": "File types",
|
|
||||||
"items": {
|
"items": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": ["IMAGE", "VIDEO"]
|
||||||
"image",
|
|
||||||
"video"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"description": "Allowed file types"
|
"description": "Allowed file types"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": ["fileTypes"]
|
||||||
"fileTypes"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"methodName": "filterPerson",
|
"methodName": "filterPerson",
|
||||||
"title": "Filter by person",
|
"title": "Filter by person",
|
||||||
"description": "Filter by detected person",
|
"description": "Filter by detected person",
|
||||||
"supportedContexts": [
|
"supportedContexts": ["person"],
|
||||||
"person"
|
|
||||||
],
|
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"personIds": {
|
"personIds": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"title": "Person IDs",
|
|
||||||
"items": {
|
"items": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"description": "List of person to match",
|
"description": "List of person to match"
|
||||||
"subType": "people-picker"
|
|
||||||
},
|
},
|
||||||
"matchAny": {
|
"matchAny": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
@@ -98,29 +78,24 @@
|
|||||||
"description": "Match any name (true) or require all names (false)"
|
"description": "Match any name (true) or require all names (false)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": ["personIds"]
|
||||||
"personIds"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
||||||
"actions": [
|
"actions": [
|
||||||
{
|
{
|
||||||
"methodName": "actionArchive",
|
"methodName": "actionArchive",
|
||||||
"title": "Archive",
|
"title": "Archive",
|
||||||
"description": "Move the asset to archive",
|
"description": "Move the asset to archive",
|
||||||
"supportedContexts": [
|
"supportedContexts": ["asset"],
|
||||||
"asset"
|
|
||||||
],
|
|
||||||
"schema": {}
|
"schema": {}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"methodName": "actionFavorite",
|
"methodName": "actionFavorite",
|
||||||
"title": "Favorite",
|
"title": "Favorite",
|
||||||
"description": "Mark the asset as favorite or unfavorite",
|
"description": "Mark the asset as favorite or unfavorite",
|
||||||
"supportedContexts": [
|
"supportedContexts": ["asset"],
|
||||||
"asset"
|
|
||||||
],
|
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -136,23 +111,16 @@
|
|||||||
"methodName": "actionAddToAlbum",
|
"methodName": "actionAddToAlbum",
|
||||||
"title": "Add to Album",
|
"title": "Add to Album",
|
||||||
"description": "Add the item to a specified album",
|
"description": "Add the item to a specified album",
|
||||||
"supportedContexts": [
|
"supportedContexts": ["asset", "person"],
|
||||||
"asset",
|
|
||||||
"person"
|
|
||||||
],
|
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"albumId": {
|
"albumId": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"title": "Album ID",
|
"description": "Target album ID"
|
||||||
"description": "Target album ID",
|
|
||||||
"subType": "album-picker"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": ["albumId"]
|
||||||
"albumId"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
778
pnpm-lock.yaml
generated
778
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -50,13 +50,15 @@ RUN --mount=type=cache,id=pnpm-cli,target=/buildcache/pnpm-store \
|
|||||||
|
|
||||||
FROM builder AS plugins
|
FROM builder AS plugins
|
||||||
|
|
||||||
|
ARG TARGETPLATFORM
|
||||||
|
|
||||||
COPY --from=ghcr.io/jdx/mise:2025.11.3@sha256:ac26f5978c0e2783f3e68e58ce75eddb83e41b89bf8747c503bac2aa9baf22c5 /usr/local/bin/mise /usr/local/bin/mise
|
COPY --from=ghcr.io/jdx/mise:2025.11.3@sha256:ac26f5978c0e2783f3e68e58ce75eddb83e41b89bf8747c503bac2aa9baf22c5 /usr/local/bin/mise /usr/local/bin/mise
|
||||||
|
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
COPY ./plugins/mise.toml ./plugins/
|
COPY ./plugins/mise.toml ./plugins/
|
||||||
ENV MISE_TRUSTED_CONFIG_PATHS=/usr/src/app/plugins/mise.toml
|
ENV MISE_TRUSTED_CONFIG_PATHS=/usr/src/app/plugins/mise.toml
|
||||||
ENV MISE_DATA_DIR=/buildcache/mise
|
ENV MISE_DATA_DIR=/buildcache/mise
|
||||||
RUN --mount=type=cache,id=mise-tools,target=/buildcache/mise \
|
RUN --mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
|
||||||
mise install --cd plugins
|
mise install --cd plugins
|
||||||
|
|
||||||
COPY ./plugins ./plugins/
|
COPY ./plugins ./plugins/
|
||||||
@@ -66,7 +68,7 @@ RUN --mount=type=cache,id=pnpm-plugins,target=/buildcache/pnpm-store \
|
|||||||
--mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \
|
--mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \
|
||||||
--mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \
|
--mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \
|
||||||
--mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \
|
--mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \
|
||||||
--mount=type=cache,id=mise-tools,target=/buildcache/mise \
|
--mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
|
||||||
cd plugins && mise run build
|
cd plugins && mise run build
|
||||||
|
|
||||||
FROM ghcr.io/immich-app/base-server-prod:202511261514@sha256:c04c1c38dd90e53455b180aedf93c3c63474c8d20ffe2c6d7a3a61a2181e6d29
|
FROM ghcr.io/immich-app/base-server-prod:202511261514@sha256:c04c1c38dd90e53455b180aedf93c3c63474c8d20ffe2c6d7a3a61a2181e6d29
|
||||||
|
|||||||
@@ -70,7 +70,7 @@
|
|||||||
"cookie": "^1.0.2",
|
"cookie": "^1.0.2",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"cron": "4.3.3",
|
"cron": "4.3.3",
|
||||||
"exiftool-vendored": "^33.0.0",
|
"exiftool-vendored": "^34.0.0",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"fast-glob": "^3.3.2",
|
"fast-glob": "^3.3.2",
|
||||||
"fluent-ffmpeg": "^2.1.2",
|
"fluent-ffmpeg": "^2.1.2",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Controller, Get, Param } from '@nestjs/common';
|
import { Controller, Get, Param } from '@nestjs/common';
|
||||||
import { ApiTags } from '@nestjs/swagger';
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
||||||
import { PluginResponseDto, PluginTriggerResponseDto } from 'src/dtos/plugin.dto';
|
import { PluginResponseDto } from 'src/dtos/plugin.dto';
|
||||||
import { Permission } from 'src/enum';
|
import { Permission } from 'src/enum';
|
||||||
import { Authenticated } from 'src/middleware/auth.guard';
|
import { Authenticated } from 'src/middleware/auth.guard';
|
||||||
import { PluginService } from 'src/services/plugin.service';
|
import { PluginService } from 'src/services/plugin.service';
|
||||||
@@ -12,17 +12,6 @@ import { UUIDParamDto } from 'src/validation';
|
|||||||
export class PluginController {
|
export class PluginController {
|
||||||
constructor(private service: PluginService) {}
|
constructor(private service: PluginService) {}
|
||||||
|
|
||||||
@Get('triggers')
|
|
||||||
@Authenticated({ permission: Permission.PluginRead })
|
|
||||||
@Endpoint({
|
|
||||||
summary: 'List all plugin triggers',
|
|
||||||
description: 'Retrieve a list of all available plugin triggers.',
|
|
||||||
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
|
|
||||||
})
|
|
||||||
getPluginTriggers(): PluginTriggerResponseDto[] {
|
|
||||||
return this.service.getTriggers();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@Authenticated({ permission: Permission.PluginRead })
|
@Authenticated({ permission: Permission.PluginRead })
|
||||||
@Endpoint({
|
@Endpoint({
|
||||||
|
|||||||
@@ -1,16 +1,9 @@
|
|||||||
import { IsNotEmpty, IsString } from 'class-validator';
|
import { IsNotEmpty, IsString } from 'class-validator';
|
||||||
import { PluginAction, PluginFilter } from 'src/database';
|
import { PluginAction, PluginFilter } from 'src/database';
|
||||||
import { PluginContext as PluginContextType, PluginTriggerType } from 'src/enum';
|
import { PluginContext } from 'src/enum';
|
||||||
import type { JSONSchema } from 'src/types/plugin-schema.types';
|
import type { JSONSchema } from 'src/types/plugin-schema.types';
|
||||||
import { ValidateEnum } from 'src/validation';
|
import { ValidateEnum } from 'src/validation';
|
||||||
|
|
||||||
export class PluginTriggerResponseDto {
|
|
||||||
@ValidateEnum({ enum: PluginTriggerType, name: 'PluginTriggerType' })
|
|
||||||
type!: PluginTriggerType;
|
|
||||||
@ValidateEnum({ enum: PluginContextType, name: 'PluginContextType' })
|
|
||||||
contextType!: PluginContextType;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class PluginResponseDto {
|
export class PluginResponseDto {
|
||||||
id!: string;
|
id!: string;
|
||||||
name!: string;
|
name!: string;
|
||||||
@@ -31,8 +24,8 @@ export class PluginFilterResponseDto {
|
|||||||
title!: string;
|
title!: string;
|
||||||
description!: string;
|
description!: string;
|
||||||
|
|
||||||
@ValidateEnum({ enum: PluginContextType, name: 'PluginContextType' })
|
@ValidateEnum({ enum: PluginContext, name: 'PluginContext' })
|
||||||
supportedContexts!: PluginContextType[];
|
supportedContexts!: PluginContext[];
|
||||||
schema!: JSONSchema | null;
|
schema!: JSONSchema | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,8 +36,8 @@ export class PluginActionResponseDto {
|
|||||||
title!: string;
|
title!: string;
|
||||||
description!: string;
|
description!: string;
|
||||||
|
|
||||||
@ValidateEnum({ enum: PluginContextType, name: 'PluginContextType' })
|
@ValidateEnum({ enum: PluginContext, name: 'PluginContext' })
|
||||||
supportedContexts!: PluginContextType[];
|
supportedContexts!: PluginContext[];
|
||||||
schema!: JSONSchema | null;
|
schema!: JSONSchema | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,9 +48,6 @@ export class WorkflowCreateDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class WorkflowUpdateDto {
|
export class WorkflowUpdateDto {
|
||||||
@ValidateEnum({ enum: PluginTriggerType, name: 'PluginTriggerType', optional: true })
|
|
||||||
triggerType?: PluginTriggerType;
|
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@Optional()
|
@Optional()
|
||||||
@@ -77,7 +74,6 @@ export class WorkflowUpdateDto {
|
|||||||
export class WorkflowResponseDto {
|
export class WorkflowResponseDto {
|
||||||
id!: string;
|
id!: string;
|
||||||
ownerId!: string;
|
ownerId!: string;
|
||||||
@ValidateEnum({ enum: PluginTriggerType, name: 'PluginTriggerType' })
|
|
||||||
triggerType!: PluginTriggerType;
|
triggerType!: PluginTriggerType;
|
||||||
name!: string | null;
|
name!: string | null;
|
||||||
description!: string;
|
description!: string;
|
||||||
|
|||||||
@@ -1,17 +1,37 @@
|
|||||||
import { PluginContext, PluginTriggerType } from 'src/enum';
|
import { PluginContext, PluginTriggerType } from 'src/enum';
|
||||||
|
import { JSONSchema } from 'src/types/plugin-schema.types';
|
||||||
|
|
||||||
export type PluginTrigger = {
|
export type PluginTrigger = {
|
||||||
|
name: string;
|
||||||
type: PluginTriggerType;
|
type: PluginTriggerType;
|
||||||
contextType: PluginContext;
|
description: string;
|
||||||
|
context: PluginContext;
|
||||||
|
schema: JSONSchema | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const pluginTriggers: PluginTrigger[] = [
|
export const pluginTriggers: PluginTrigger[] = [
|
||||||
{
|
{
|
||||||
|
name: 'Asset Uploaded',
|
||||||
type: PluginTriggerType.AssetCreate,
|
type: PluginTriggerType.AssetCreate,
|
||||||
contextType: PluginContext.Asset,
|
description: 'Triggered when a new asset is uploaded',
|
||||||
|
context: PluginContext.Asset,
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
assetType: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Type of the asset',
|
||||||
|
default: 'ALL',
|
||||||
|
enum: ['Image', 'Video', 'All'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
name: 'Person Recognized',
|
||||||
type: PluginTriggerType.PersonRecognized,
|
type: PluginTriggerType.PersonRecognized,
|
||||||
contextType: PluginContext.Person,
|
description: 'Triggered when a person is detected in an asset',
|
||||||
|
context: PluginContext.Person,
|
||||||
|
schema: null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ from
|
|||||||
"workflow"
|
"workflow"
|
||||||
where
|
where
|
||||||
"id" = $1
|
"id" = $1
|
||||||
order by
|
|
||||||
"createdAt" desc
|
|
||||||
|
|
||||||
-- WorkflowRepository.getWorkflowsByOwner
|
-- WorkflowRepository.getWorkflowsByOwner
|
||||||
select
|
select
|
||||||
@@ -18,7 +16,7 @@ from
|
|||||||
where
|
where
|
||||||
"ownerId" = $1
|
"ownerId" = $1
|
||||||
order by
|
order by
|
||||||
"createdAt" desc
|
"name"
|
||||||
|
|
||||||
-- WorkflowRepository.getWorkflowsByTrigger
|
-- WorkflowRepository.getWorkflowsByTrigger
|
||||||
select
|
select
|
||||||
|
|||||||
@@ -12,22 +12,12 @@ export class WorkflowRepository {
|
|||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID] })
|
@GenerateSql({ params: [DummyValue.UUID] })
|
||||||
getWorkflow(id: string) {
|
getWorkflow(id: string) {
|
||||||
return this.db
|
return this.db.selectFrom('workflow').selectAll().where('id', '=', id).executeTakeFirst();
|
||||||
.selectFrom('workflow')
|
|
||||||
.selectAll()
|
|
||||||
.where('id', '=', id)
|
|
||||||
.orderBy('createdAt', 'desc')
|
|
||||||
.executeTakeFirst();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID] })
|
@GenerateSql({ params: [DummyValue.UUID] })
|
||||||
getWorkflowsByOwner(ownerId: string) {
|
getWorkflowsByOwner(ownerId: string) {
|
||||||
return this.db
|
return this.db.selectFrom('workflow').selectAll().where('ownerId', '=', ownerId).orderBy('name').execute();
|
||||||
.selectFrom('workflow')
|
|
||||||
.selectAll()
|
|
||||||
.where('ownerId', '=', ownerId)
|
|
||||||
.orderBy('createdAt', 'desc')
|
|
||||||
.execute();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [PluginTriggerType.AssetCreate] })
|
@GenerateSql({ params: [PluginTriggerType.AssetCreate] })
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ import { join } from 'node:path';
|
|||||||
import { Asset, WorkflowAction, WorkflowFilter } from 'src/database';
|
import { Asset, WorkflowAction, WorkflowFilter } from 'src/database';
|
||||||
import { OnEvent, OnJob } from 'src/decorators';
|
import { OnEvent, OnJob } from 'src/decorators';
|
||||||
import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto';
|
import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto';
|
||||||
import { mapPlugin, PluginResponseDto, PluginTriggerResponseDto } from 'src/dtos/plugin.dto';
|
import { mapPlugin, PluginResponseDto } from 'src/dtos/plugin.dto';
|
||||||
import { JobName, JobStatus, PluginTriggerType, QueueName } from 'src/enum';
|
import { JobName, JobStatus, PluginTriggerType, QueueName } from 'src/enum';
|
||||||
import { pluginTriggers } from 'src/plugins';
|
|
||||||
import { ArgOf } from 'src/repositories/event.repository';
|
import { ArgOf } from 'src/repositories/event.repository';
|
||||||
import { BaseService } from 'src/services/base.service';
|
import { BaseService } from 'src/services/base.service';
|
||||||
import { PluginHostFunctions } from 'src/services/plugin-host.functions';
|
import { PluginHostFunctions } from 'src/services/plugin-host.functions';
|
||||||
@@ -51,10 +50,6 @@ export class PluginService extends BaseService {
|
|||||||
await this.loadPlugins();
|
await this.loadPlugins();
|
||||||
}
|
}
|
||||||
|
|
||||||
getTriggers(): PluginTriggerResponseDto[] {
|
|
||||||
return pluginTriggers;
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
//
|
||||||
// CRUD operations for plugins
|
// CRUD operations for plugins
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ import { BaseService } from 'src/services/base.service';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class WorkflowService extends BaseService {
|
export class WorkflowService extends BaseService {
|
||||||
async create(auth: AuthDto, dto: WorkflowCreateDto): Promise<WorkflowResponseDto> {
|
async create(auth: AuthDto, dto: WorkflowCreateDto): Promise<WorkflowResponseDto> {
|
||||||
const context = this.getContextForTrigger(dto.triggerType);
|
const trigger = this.getTriggerOrFail(dto.triggerType);
|
||||||
|
|
||||||
const filterInserts = await this.validateAndMapFilters(dto.filters, context);
|
const filterInserts = await this.validateAndMapFilters(dto.filters, trigger.context);
|
||||||
const actionInserts = await this.validateAndMapActions(dto.actions, context);
|
const actionInserts = await this.validateAndMapActions(dto.actions, trigger.context);
|
||||||
|
|
||||||
const workflow = await this.workflowRepository.createWorkflow(
|
const workflow = await this.workflowRepository.createWorkflow(
|
||||||
{
|
{
|
||||||
@@ -56,11 +56,11 @@ export class WorkflowService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const workflow = await this.findOrFail(id);
|
const workflow = await this.findOrFail(id);
|
||||||
const context = this.getContextForTrigger(dto.triggerType ?? workflow.triggerType);
|
const trigger = this.getTriggerOrFail(workflow.triggerType);
|
||||||
|
|
||||||
const { filters, actions, ...workflowUpdate } = dto;
|
const { filters, actions, ...workflowUpdate } = dto;
|
||||||
const filterInserts = filters && (await this.validateAndMapFilters(filters, context));
|
const filterInserts = filters && (await this.validateAndMapFilters(filters, trigger.context));
|
||||||
const actionInserts = actions && (await this.validateAndMapActions(actions, context));
|
const actionInserts = actions && (await this.validateAndMapActions(actions, trigger.context));
|
||||||
|
|
||||||
const updatedWorkflow = await this.workflowRepository.updateWorkflow(
|
const updatedWorkflow = await this.workflowRepository.updateWorkflow(
|
||||||
id,
|
id,
|
||||||
@@ -124,12 +124,12 @@ export class WorkflowService extends BaseService {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
private getContextForTrigger(type: PluginTriggerType) {
|
private getTriggerOrFail(triggerType: PluginTriggerType) {
|
||||||
const trigger = pluginTriggers.find((t) => t.type === type);
|
const trigger = pluginTriggers.find((t) => t.type === triggerType);
|
||||||
if (!trigger) {
|
if (!trigger) {
|
||||||
throw new BadRequestException(`Invalid trigger type: ${type}`);
|
throw new BadRequestException(`Invalid trigger type: ${triggerType}`);
|
||||||
}
|
}
|
||||||
return trigger.contextType;
|
return trigger;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async findOrFail(id: string) {
|
private async findOrFail(id: string) {
|
||||||
|
|||||||
@@ -611,100 +611,6 @@ describe(WorkflowService.name, () => {
|
|||||||
sut.update(auth, created.id, { actions: [{ pluginActionId: factory.uuid(), actionConfig: {} }] }),
|
sut.update(auth, created.id, { actions: [{ pluginActionId: factory.uuid(), actionConfig: {} }] }),
|
||||||
).rejects.toThrow();
|
).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should update trigger type', async () => {
|
|
||||||
const { sut, ctx } = setup();
|
|
||||||
const { user } = await ctx.newUser();
|
|
||||||
const auth = factory.auth({ user });
|
|
||||||
|
|
||||||
const created = await sut.create(auth, {
|
|
||||||
triggerType: PluginTriggerType.PersonRecognized,
|
|
||||||
name: 'test-workflow',
|
|
||||||
description: 'Test',
|
|
||||||
enabled: true,
|
|
||||||
filters: [],
|
|
||||||
actions: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
await sut.update(auth, created.id, {
|
|
||||||
triggerType: PluginTriggerType.AssetCreate,
|
|
||||||
});
|
|
||||||
|
|
||||||
const fetched = await sut.get(auth, created.id);
|
|
||||||
expect(fetched.triggerType).toBe(PluginTriggerType.AssetCreate);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should add filters', async () => {
|
|
||||||
const { sut, ctx } = setup();
|
|
||||||
const { user } = await ctx.newUser();
|
|
||||||
const auth = factory.auth({ user });
|
|
||||||
|
|
||||||
const created = await sut.create(auth, {
|
|
||||||
triggerType: PluginTriggerType.AssetCreate,
|
|
||||||
name: 'test-workflow',
|
|
||||||
description: 'Test',
|
|
||||||
enabled: true,
|
|
||||||
filters: [],
|
|
||||||
actions: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
await sut.update(auth, created.id, {
|
|
||||||
filters: [
|
|
||||||
{ pluginFilterId: testFilterId, filterConfig: { first: true } },
|
|
||||||
{ pluginFilterId: testFilterId, filterConfig: { second: true } },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const fetched = await sut.get(auth, created.id);
|
|
||||||
expect(fetched.filters).toHaveLength(2);
|
|
||||||
expect(fetched.filters[0].filterConfig).toEqual({ first: true });
|
|
||||||
expect(fetched.filters[1].filterConfig).toEqual({ second: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should replace existing filters', async () => {
|
|
||||||
const { sut, ctx } = setup();
|
|
||||||
const { user } = await ctx.newUser();
|
|
||||||
const auth = factory.auth({ user });
|
|
||||||
|
|
||||||
const created = await sut.create(auth, {
|
|
||||||
triggerType: PluginTriggerType.AssetCreate,
|
|
||||||
name: 'test-workflow',
|
|
||||||
description: 'Test',
|
|
||||||
enabled: true,
|
|
||||||
filters: [{ pluginFilterId: testFilterId, filterConfig: { original: true } }],
|
|
||||||
actions: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
await sut.update(auth, created.id, {
|
|
||||||
filters: [{ pluginFilterId: testFilterId, filterConfig: { replaced: true } }],
|
|
||||||
});
|
|
||||||
|
|
||||||
const fetched = await sut.get(auth, created.id);
|
|
||||||
expect(fetched.filters).toHaveLength(1);
|
|
||||||
expect(fetched.filters[0].filterConfig).toEqual({ replaced: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should remove existing filters', async () => {
|
|
||||||
const { sut, ctx } = setup();
|
|
||||||
const { user } = await ctx.newUser();
|
|
||||||
const auth = factory.auth({ user });
|
|
||||||
|
|
||||||
const created = await sut.create(auth, {
|
|
||||||
triggerType: PluginTriggerType.AssetCreate,
|
|
||||||
name: 'test-workflow',
|
|
||||||
description: 'Test',
|
|
||||||
enabled: true,
|
|
||||||
filters: [{ pluginFilterId: testFilterId, filterConfig: { toRemove: true } }],
|
|
||||||
actions: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
await sut.update(auth, created.id, {
|
|
||||||
filters: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
const fetched = await sut.get(auth, created.id);
|
|
||||||
expect(fetched.filters).toHaveLength(0);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('delete', () => {
|
describe('delete', () => {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
"@formatjs/icu-messageformat-parser": "^2.9.8",
|
"@formatjs/icu-messageformat-parser": "^2.9.8",
|
||||||
"@immich/justified-layout-wasm": "^0.4.3",
|
"@immich/justified-layout-wasm": "^0.4.3",
|
||||||
"@immich/sdk": "file:../open-api/typescript-sdk",
|
"@immich/sdk": "file:../open-api/typescript-sdk",
|
||||||
"@immich/ui": "^0.50.0",
|
"@immich/ui": "^0.50.1",
|
||||||
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
|
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
|
||||||
"@mdi/js": "^7.4.47",
|
"@mdi/js": "^7.4.47",
|
||||||
"@photo-sphere-viewer/core": "^5.14.0",
|
"@photo-sphere-viewer/core": "^5.14.0",
|
||||||
@@ -58,7 +58,6 @@
|
|||||||
"socket.io-client": "~4.8.0",
|
"socket.io-client": "~4.8.0",
|
||||||
"svelte-gestures": "^5.2.2",
|
"svelte-gestures": "^5.2.2",
|
||||||
"svelte-i18n": "^4.0.1",
|
"svelte-i18n": "^4.0.1",
|
||||||
"svelte-jsoneditor": "^3.10.0",
|
|
||||||
"svelte-maplibre": "^1.2.5",
|
"svelte-maplibre": "^1.2.5",
|
||||||
"svelte-persisted-store": "^0.12.0",
|
"svelte-persisted-store": "^0.12.0",
|
||||||
"tabbable": "^6.2.0",
|
"tabbable": "^6.2.0",
|
||||||
@@ -99,7 +98,7 @@
|
|||||||
"prettier-plugin-sort-json": "^4.1.1",
|
"prettier-plugin-sort-json": "^4.1.1",
|
||||||
"prettier-plugin-svelte": "^3.3.3",
|
"prettier-plugin-svelte": "^3.3.3",
|
||||||
"rollup-plugin-visualizer": "^6.0.0",
|
"rollup-plugin-visualizer": "^6.0.0",
|
||||||
"svelte": "5.45.2",
|
"svelte": "5.43.3",
|
||||||
"svelte-check": "^4.1.5",
|
"svelte-check": "^4.1.5",
|
||||||
"svelte-eslint-parser": "^1.3.3",
|
"svelte-eslint-parser": "^1.3.3",
|
||||||
"tailwindcss": "^4.1.7",
|
"tailwindcss": "^4.1.7",
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
export interface DragAndDropOptions {
|
|
||||||
index: number;
|
|
||||||
onDragStart?: (index: number) => void;
|
|
||||||
onDragEnter?: (index: number) => void;
|
|
||||||
onDrop?: (e: DragEvent, index: number) => void;
|
|
||||||
onDragEnd?: () => void;
|
|
||||||
isDragging?: boolean;
|
|
||||||
isDragOver?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function dragAndDrop(node: HTMLElement, options: DragAndDropOptions) {
|
|
||||||
let { index, onDragStart, onDragEnter, onDrop, onDragEnd, isDragging, isDragOver } = options;
|
|
||||||
|
|
||||||
const isFormElement = (element: HTMLElement) => {
|
|
||||||
return element.tagName === 'INPUT' || element.tagName === 'TEXTAREA' || element.tagName === 'SELECT';
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragStart = (e: DragEvent) => {
|
|
||||||
// Prevent drag if it originated from an input, textarea, or select element
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
if (isFormElement(target)) {
|
|
||||||
e.preventDefault();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onDragStart?.(index);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragEnter = () => {
|
|
||||||
onDragEnter?.(index);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragOver = (e: DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDrop = (e: DragEvent) => {
|
|
||||||
onDrop?.(e, index);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragEnd = () => {
|
|
||||||
onDragEnd?.();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Disable draggable when focusing on form elements (fixes Firefox input interaction)
|
|
||||||
const handleFocusIn = (e: FocusEvent) => {
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
if (isFormElement(target)) {
|
|
||||||
node.setAttribute('draggable', 'false');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFocusOut = (e: FocusEvent) => {
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
if (isFormElement(target)) {
|
|
||||||
node.setAttribute('draggable', 'true');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
node.setAttribute('draggable', 'true');
|
|
||||||
node.setAttribute('role', 'button');
|
|
||||||
node.setAttribute('tabindex', '0');
|
|
||||||
|
|
||||||
node.addEventListener('dragstart', handleDragStart);
|
|
||||||
node.addEventListener('dragenter', handleDragEnter);
|
|
||||||
node.addEventListener('dragover', handleDragOver);
|
|
||||||
node.addEventListener('drop', handleDrop);
|
|
||||||
node.addEventListener('dragend', handleDragEnd);
|
|
||||||
node.addEventListener('focusin', handleFocusIn);
|
|
||||||
node.addEventListener('focusout', handleFocusOut);
|
|
||||||
|
|
||||||
// Update classes based on drag state
|
|
||||||
const updateClasses = (dragging: boolean, dragOver: boolean) => {
|
|
||||||
// Remove all drag-related classes first
|
|
||||||
node.classList.remove('opacity-50', 'border-gray-400', 'dark:border-gray-500', 'border-solid');
|
|
||||||
|
|
||||||
// Add back only the active ones
|
|
||||||
if (dragging) {
|
|
||||||
node.classList.add('opacity-50');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dragOver) {
|
|
||||||
node.classList.add('border-gray-400', 'dark:border-gray-500', 'border-solid');
|
|
||||||
node.classList.remove('border-transparent');
|
|
||||||
} else {
|
|
||||||
node.classList.add('border-transparent');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
updateClasses(isDragging || false, isDragOver || false);
|
|
||||||
|
|
||||||
return {
|
|
||||||
update(newOptions: DragAndDropOptions) {
|
|
||||||
index = newOptions.index;
|
|
||||||
onDragStart = newOptions.onDragStart;
|
|
||||||
onDragEnter = newOptions.onDragEnter;
|
|
||||||
onDrop = newOptions.onDrop;
|
|
||||||
onDragEnd = newOptions.onDragEnd;
|
|
||||||
|
|
||||||
const newIsDragging = newOptions.isDragging || false;
|
|
||||||
const newIsDragOver = newOptions.isDragOver || false;
|
|
||||||
|
|
||||||
if (newIsDragging !== isDragging || newIsDragOver !== isDragOver) {
|
|
||||||
isDragging = newIsDragging;
|
|
||||||
isDragOver = newIsDragOver;
|
|
||||||
updateClasses(isDragging, isDragOver);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
destroy() {
|
|
||||||
node.removeEventListener('dragstart', handleDragStart);
|
|
||||||
node.removeEventListener('dragenter', handleDragEnter);
|
|
||||||
node.removeEventListener('dragover', handleDragOver);
|
|
||||||
node.removeEventListener('drop', handleDrop);
|
|
||||||
node.removeEventListener('dragend', handleDragEnd);
|
|
||||||
node.removeEventListener('focusin', handleFocusIn);
|
|
||||||
node.removeEventListener('focusout', handleFocusOut);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 5.2 KiB |
@@ -1,105 +0,0 @@
|
|||||||
import type { Attachment } from 'svelte/attachments';
|
|
||||||
|
|
||||||
export interface DragAndDropOptions {
|
|
||||||
index: number;
|
|
||||||
onDragStart?: (index: number) => void;
|
|
||||||
onDragEnter?: (index: number) => void;
|
|
||||||
onDrop?: (e: DragEvent, index: number) => void;
|
|
||||||
onDragEnd?: () => void;
|
|
||||||
isDragging?: boolean;
|
|
||||||
isDragOver?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function dragAndDrop(options: DragAndDropOptions): Attachment {
|
|
||||||
return (node: Element) => {
|
|
||||||
const element = node as HTMLElement;
|
|
||||||
const { index, onDragStart, onDragEnter, onDrop, onDragEnd, isDragging, isDragOver } = options;
|
|
||||||
|
|
||||||
const isFormElement = (el: HTMLElement) => {
|
|
||||||
return el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT';
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragStart = (e: DragEvent) => {
|
|
||||||
// Prevent drag if it originated from an input, textarea, or select element
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
if (isFormElement(target)) {
|
|
||||||
e.preventDefault();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onDragStart?.(index);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragEnter = () => {
|
|
||||||
onDragEnter?.(index);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragOver = (e: DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDrop = (e: DragEvent) => {
|
|
||||||
onDrop?.(e, index);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragEnd = () => {
|
|
||||||
onDragEnd?.();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Disable draggable when focusing on form elements (fixes Firefox input interaction)
|
|
||||||
const handleFocusIn = (e: FocusEvent) => {
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
if (isFormElement(target)) {
|
|
||||||
element.setAttribute('draggable', 'false');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFocusOut = (e: FocusEvent) => {
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
if (isFormElement(target)) {
|
|
||||||
element.setAttribute('draggable', 'true');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update classes based on drag state
|
|
||||||
const updateClasses = (dragging: boolean, dragOver: boolean) => {
|
|
||||||
// Remove all drag-related classes first
|
|
||||||
element.classList.remove('opacity-50', 'border-light-500', 'border-solid');
|
|
||||||
|
|
||||||
// Add back only the active ones
|
|
||||||
if (dragging) {
|
|
||||||
element.classList.add('opacity-50');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dragOver) {
|
|
||||||
element.classList.add('border-light-500', 'border-solid');
|
|
||||||
element.classList.remove('border-transparent');
|
|
||||||
} else {
|
|
||||||
element.classList.add('border-transparent');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
element.setAttribute('draggable', 'true');
|
|
||||||
element.setAttribute('role', 'button');
|
|
||||||
element.setAttribute('tabindex', '0');
|
|
||||||
|
|
||||||
element.addEventListener('dragstart', handleDragStart);
|
|
||||||
element.addEventListener('dragenter', handleDragEnter);
|
|
||||||
element.addEventListener('dragover', handleDragOver);
|
|
||||||
element.addEventListener('drop', handleDrop);
|
|
||||||
element.addEventListener('dragend', handleDragEnd);
|
|
||||||
element.addEventListener('focusin', handleFocusIn);
|
|
||||||
element.addEventListener('focusout', handleFocusOut);
|
|
||||||
|
|
||||||
updateClasses(isDragging || false, isDragOver || false);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
element.removeEventListener('dragstart', handleDragStart);
|
|
||||||
element.removeEventListener('dragenter', handleDragEnter);
|
|
||||||
element.removeEventListener('dragover', handleDragOver);
|
|
||||||
element.removeEventListener('drop', handleDrop);
|
|
||||||
element.removeEventListener('dragend', handleDragEnd);
|
|
||||||
element.removeEventListener('focusin', handleFocusIn);
|
|
||||||
element.removeEventListener('focusout', handleFocusOut);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<tr
|
<tr
|
||||||
class="flex h-12 w-full place-items-center border-3 border-transparent p-2 text-center even:bg-subtle/20 odd:bg-subtle/80 hover:cursor-pointer hover:border-immich-primary/75 odd:dark:bg-immich-dark-gray/75 even:dark:bg-immich-dark-gray/50 dark:hover:border-immich-dark-primary/75 md:p-5"
|
class="flex w-full place-items-center border-3 border-transparent p-2 text-center even:bg-subtle/20 odd:bg-subtle/80 hover:cursor-pointer hover:border-immich-primary/75 odd:dark:bg-immich-dark-gray/75 even:dark:bg-immich-dark-gray/50 dark:hover:border-immich-dark-primary/75 md:px-5 md:py-2"
|
||||||
onclick={() => goto(resolve(`${AppRoute.ALBUMS}/${album.id}`))}
|
onclick={() => goto(resolve(`${AppRoute.ALBUMS}/${album.id}`))}
|
||||||
{oncontextmenu}
|
{oncontextmenu}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -126,6 +126,7 @@
|
|||||||
|
|
||||||
const onMouseLeave = () => {
|
const onMouseLeave = () => {
|
||||||
mouseOver = false;
|
mouseOver = false;
|
||||||
|
onMouseEvent?.({ isMouseOver: false, selectedGroupIndex: groupIndex });
|
||||||
};
|
};
|
||||||
|
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
mdiCrosshairsGps,
|
mdiCrosshairsGps,
|
||||||
mdiImageSizeSelectLarge,
|
mdiImageSizeSelectLarge,
|
||||||
mdiLinkEdit,
|
mdiLinkEdit,
|
||||||
mdiStateMachine,
|
|
||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
@@ -17,7 +16,6 @@
|
|||||||
{ href: AppRoute.DUPLICATES, icon: mdiContentDuplicate, label: $t('review_duplicates') },
|
{ href: AppRoute.DUPLICATES, icon: mdiContentDuplicate, label: $t('review_duplicates') },
|
||||||
{ href: AppRoute.LARGE_FILES, icon: mdiImageSizeSelectLarge, label: $t('review_large_files') },
|
{ href: AppRoute.LARGE_FILES, icon: mdiImageSizeSelectLarge, label: $t('review_large_files') },
|
||||||
{ href: AppRoute.GEOLOCATION, icon: mdiCrosshairsGps, label: $t('manage_geolocation') },
|
{ href: AppRoute.GEOLOCATION, icon: mdiCrosshairsGps, label: $t('manage_geolocation') },
|
||||||
{ href: AppRoute.WORKFLOWS, icon: mdiStateMachine, label: $t('workflows') },
|
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,171 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { getComponentDefaultValue, getComponentFromSchema } from '$lib/utils/workflow';
|
|
||||||
import { Field, Input, MultiSelect, Select, Switch, Text } from '@immich/ui';
|
|
||||||
import WorkflowPickerField from './WorkflowPickerField.svelte';
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
schema: object | null;
|
|
||||||
config: Record<string, unknown>;
|
|
||||||
configKey?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
let { schema = null, config = $bindable({}), configKey }: Props = $props();
|
|
||||||
|
|
||||||
const components = $derived(getComponentFromSchema(schema));
|
|
||||||
|
|
||||||
// Get the actual config object to work with
|
|
||||||
const actualConfig = $derived(configKey ? (config[configKey] as Record<string, unknown>) || {} : config);
|
|
||||||
|
|
||||||
// Update function that handles nested config
|
|
||||||
const updateConfig = (key: string, value: unknown) => {
|
|
||||||
config = configKey ? { ...config, [configKey]: { ...actualConfig, [key]: value } } : { ...config, [key]: value };
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateConfigBatch = (updates: Record<string, unknown>) => {
|
|
||||||
config = configKey ? { ...config, [configKey]: { ...actualConfig, ...updates } } : { ...config, ...updates };
|
|
||||||
};
|
|
||||||
|
|
||||||
// Derive which keys need initialization (missing from actualConfig)
|
|
||||||
const uninitializedKeys = $derived.by(() => {
|
|
||||||
if (!components) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.entries(components)
|
|
||||||
.filter(([key]) => actualConfig[key] === undefined)
|
|
||||||
.map(([key, component]) => ({ key, component, defaultValue: getComponentDefaultValue(component) }));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Derive the batch updates needed
|
|
||||||
const pendingUpdates = $derived.by(() => {
|
|
||||||
const updates: Record<string, unknown> = {};
|
|
||||||
for (const { key, defaultValue } of uninitializedKeys) {
|
|
||||||
updates[key] = defaultValue;
|
|
||||||
}
|
|
||||||
return updates;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize config namespace if needed
|
|
||||||
$effect(() => {
|
|
||||||
if (configKey && !config[configKey]) {
|
|
||||||
config = { ...config, [configKey]: {} };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Apply pending config updates
|
|
||||||
$effect(() => {
|
|
||||||
if (Object.keys(pendingUpdates).length > 0) {
|
|
||||||
updateConfigBatch(pendingUpdates);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const isPickerField = (subType: string | undefined) => subType === 'album-picker' || subType === 'people-picker';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if components}
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
{#each Object.entries(components) as [key, component] (key)}
|
|
||||||
{@const label = component.title || component.label || key}
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-1 border bg-light p-4 rounded-xl">
|
|
||||||
<!-- Select component -->
|
|
||||||
{#if component.type === 'select'}
|
|
||||||
{#if isPickerField(component.subType)}
|
|
||||||
<WorkflowPickerField
|
|
||||||
{component}
|
|
||||||
configKey={key}
|
|
||||||
value={actualConfig[key] as string | string[]}
|
|
||||||
onchange={(value) => updateConfig(key, value)}
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
{@const options = component.options?.map((opt) => {
|
|
||||||
return { label: opt.label, value: String(opt.value) };
|
|
||||||
}) || [{ label: 'N/A', value: '' }]}
|
|
||||||
{@const currentValue = actualConfig[key]}
|
|
||||||
{@const selectedItem = options.find((opt) => opt.value === String(currentValue)) ?? options[0]}
|
|
||||||
|
|
||||||
<Field
|
|
||||||
{label}
|
|
||||||
required={component.required}
|
|
||||||
description={component.description}
|
|
||||||
requiredIndicator={component.required}
|
|
||||||
>
|
|
||||||
<Select data={options} onChange={(opt) => updateConfig(key, opt.value)} value={selectedItem} />
|
|
||||||
</Field>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- MultiSelect component -->
|
|
||||||
{:else if component.type === 'multiselect'}
|
|
||||||
{#if isPickerField(component.subType)}
|
|
||||||
<WorkflowPickerField
|
|
||||||
{component}
|
|
||||||
configKey={key}
|
|
||||||
value={actualConfig[key] as string | string[]}
|
|
||||||
onchange={(value) => updateConfig(key, value)}
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
{@const options = component.options?.map((opt) => {
|
|
||||||
return { label: opt.label, value: String(opt.value) };
|
|
||||||
}) || [{ label: 'N/A', value: '' }]}
|
|
||||||
{@const currentValues = (actualConfig[key] as string[]) ?? []}
|
|
||||||
{@const selectedItems = options.filter((opt) => currentValues.includes(opt.value))}
|
|
||||||
|
|
||||||
<Field
|
|
||||||
{label}
|
|
||||||
required={component.required}
|
|
||||||
description={component.description}
|
|
||||||
requiredIndicator={component.required}
|
|
||||||
>
|
|
||||||
<MultiSelect
|
|
||||||
data={options}
|
|
||||||
onChange={(opt) =>
|
|
||||||
updateConfig(
|
|
||||||
key,
|
|
||||||
opt.map((o) => o.value),
|
|
||||||
)}
|
|
||||||
values={selectedItems}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Switch component -->
|
|
||||||
{:else if component.type === 'switch'}
|
|
||||||
{@const checked = Boolean(actualConfig[key])}
|
|
||||||
<Field
|
|
||||||
{label}
|
|
||||||
description={component.description}
|
|
||||||
requiredIndicator={component.required}
|
|
||||||
required={component.required}
|
|
||||||
>
|
|
||||||
<Switch {checked} onCheckedChange={(check) => updateConfig(key, check)} />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<!-- Text input -->
|
|
||||||
{:else if isPickerField(component.subType)}
|
|
||||||
<WorkflowPickerField
|
|
||||||
{component}
|
|
||||||
configKey={key}
|
|
||||||
value={actualConfig[key] as string | string[]}
|
|
||||||
onchange={(value) => updateConfig(key, value)}
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<Field
|
|
||||||
{label}
|
|
||||||
description={component.description}
|
|
||||||
requiredIndicator={component.required}
|
|
||||||
required={component.required}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
id={key}
|
|
||||||
value={actualConfig[key] as string}
|
|
||||||
oninput={(e) => updateConfig(key, e.currentTarget.value)}
|
|
||||||
required={component.required}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<Text size="small" color="muted">No configuration required</Text>
|
|
||||||
{/if}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
type Props = {
|
|
||||||
animated?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
let { animated = true }: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="flex justify-center py-2">
|
|
||||||
<div class="relative h-12 w-0.5">
|
|
||||||
<div class="absolute inset-0 bg-linear-to-b from-primary/30 via-primary/50 to-primary/30"></div>
|
|
||||||
{#if animated}
|
|
||||||
<div class="absolute inset-0 bg-linear-to-b from-transparent via-primary to-transparent flow-pulse"></div>
|
|
||||||
{/if}
|
|
||||||
<div class="absolute left-1/2 top-0 -translate-x-1/2 -translate-y-1/2">
|
|
||||||
<div class="h-2 w-2 rounded-full bg-primary shadow-sm shadow-primary/50"></div>
|
|
||||||
</div>
|
|
||||||
<div class="absolute left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2">
|
|
||||||
<div class="h-2 w-2 rounded-full bg-primary shadow-sm shadow-primary/50"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
@keyframes flow {
|
|
||||||
0% {
|
|
||||||
transform: translateY(-25%);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: translateY(25%);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.flow-pulse {
|
|
||||||
animation: flow 2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { themeManager } from '$lib/managers/theme-manager.svelte';
|
|
||||||
import type { WorkflowPayload } from '$lib/services/workflow.service';
|
|
||||||
import { Button, Card, CardBody, CardDescription, CardHeader, CardTitle, Icon, VStack } from '@immich/ui';
|
|
||||||
import { mdiCodeJson } from '@mdi/js';
|
|
||||||
import { JSONEditor, Mode, type Content, type OnChangeStatus } from 'svelte-jsoneditor';
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
jsonContent: WorkflowPayload;
|
|
||||||
onApply: () => void;
|
|
||||||
onContentChange: (content: WorkflowPayload) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
let { jsonContent, onApply, onContentChange }: Props = $props();
|
|
||||||
|
|
||||||
let content: Content = $derived({ json: jsonContent });
|
|
||||||
let canApply = $state(false);
|
|
||||||
let editorClass = $derived(themeManager.isDark ? 'jse-theme-dark' : '');
|
|
||||||
|
|
||||||
const handleChange = (updated: Content, _: Content, status: OnChangeStatus) => {
|
|
||||||
if (status.contentErrors) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
canApply = true;
|
|
||||||
|
|
||||||
if ('text' in updated && updated.text !== undefined) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(updated.text);
|
|
||||||
onContentChange(parsed);
|
|
||||||
} catch (error_) {
|
|
||||||
console.error('Invalid JSON in text mode:', error_);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleApply = () => {
|
|
||||||
onApply();
|
|
||||||
canApply = false;
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<VStack gap={4}>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div class="flex items-start justify-between gap-3">
|
|
||||||
<div class="flex items-start gap-3">
|
|
||||||
<Icon icon={mdiCodeJson} size="20" class="mt-1" />
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<CardTitle>Workflow JSON</CardTitle>
|
|
||||||
<CardDescription>Edit the workflow configuration directly in JSON format</CardDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button size="small" color="primary" onclick={handleApply} disabled={!canApply}>Apply Changes</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardBody>
|
|
||||||
<VStack gap={2}>
|
|
||||||
<div class="w-full h-[600px] rounded-lg overflow-hidden border {editorClass}">
|
|
||||||
<JSONEditor {content} onChange={handleChange} mainMenuBar={false} mode={Mode.text} />
|
|
||||||
</div>
|
|
||||||
</VStack>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
</VStack>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
@import 'svelte-jsoneditor/themes/jse-theme-dark.css';
|
|
||||||
</style>
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import WorkflowPickerItemCard from '$lib/components/workflows/WorkflowPickerItemCard.svelte';
|
|
||||||
import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte';
|
|
||||||
import PeoplePickerModal from '$lib/modals/PeoplePickerModal.svelte';
|
|
||||||
import { fetchPickerMetadata, type PickerMetadata } from '$lib/services/workflow.service';
|
|
||||||
import type { ComponentConfig } from '$lib/utils/workflow';
|
|
||||||
import type { AlbumResponseDto, PersonResponseDto } from '@immich/sdk';
|
|
||||||
import { Button, Field, modalManager } from '@immich/ui';
|
|
||||||
import { mdiPlus } from '@mdi/js';
|
|
||||||
import { t } from 'svelte-i18n';
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
component: ComponentConfig;
|
|
||||||
configKey: string;
|
|
||||||
value: string | string[] | undefined;
|
|
||||||
onchange: (value: string | string[]) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
let { component, configKey, value = $bindable(), onchange }: Props = $props();
|
|
||||||
|
|
||||||
const label = $derived(component.title || component.label || configKey);
|
|
||||||
const subType = $derived(component.subType as 'album-picker' | 'people-picker');
|
|
||||||
const isAlbum = $derived(subType === 'album-picker');
|
|
||||||
const multiple = $derived(component.type === 'multiselect' || Array.isArray(value));
|
|
||||||
|
|
||||||
let pickerMetadata = $state<PickerMetadata | undefined>();
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!value) {
|
|
||||||
pickerMetadata = undefined;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!pickerMetadata) {
|
|
||||||
void loadMetadata();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const loadMetadata = async () => {
|
|
||||||
pickerMetadata = await fetchPickerMetadata(value, subType);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePicker = async () => {
|
|
||||||
if (isAlbum) {
|
|
||||||
const albums = await modalManager.show(AlbumPickerModal, { shared: false });
|
|
||||||
if (albums && albums.length > 0) {
|
|
||||||
const newValue = multiple ? albums.map((album) => album.id) : albums[0].id;
|
|
||||||
onchange(newValue);
|
|
||||||
pickerMetadata = multiple ? albums : albums[0];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const currentIds = (Array.isArray(value) ? value : []) as string[];
|
|
||||||
const excludedIds = multiple ? currentIds : [];
|
|
||||||
const people = await modalManager.show(PeoplePickerModal, { multiple, excludedIds });
|
|
||||||
if (people && people.length > 0) {
|
|
||||||
const newValue = multiple ? people.map((person) => person.id) : people[0].id;
|
|
||||||
onchange(newValue);
|
|
||||||
pickerMetadata = multiple ? people : people[0];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeSelection = () => {
|
|
||||||
onchange(multiple ? [] : '');
|
|
||||||
pickerMetadata = undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeItemFromSelection = (itemId: string) => {
|
|
||||||
if (!Array.isArray(value)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newValue = value.filter((id) => id !== itemId);
|
|
||||||
onchange(newValue);
|
|
||||||
|
|
||||||
if (Array.isArray(pickerMetadata)) {
|
|
||||||
pickerMetadata = pickerMetadata.filter((item) => item.id !== itemId) as AlbumResponseDto[] | PersonResponseDto[];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getButtonText = () => {
|
|
||||||
if (isAlbum) {
|
|
||||||
return multiple ? $t('select_albums') : $t('select_album');
|
|
||||||
}
|
|
||||||
return multiple ? $t('select_people') : $t('select_person');
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Field {label} required={component.required} description={component.description} requiredIndicator={component.required}>
|
|
||||||
<div class="flex flex-col gap-3">
|
|
||||||
{#if pickerMetadata && !Array.isArray(pickerMetadata)}
|
|
||||||
<WorkflowPickerItemCard item={pickerMetadata} {isAlbum} onRemove={removeSelection} />
|
|
||||||
{:else if pickerMetadata && Array.isArray(pickerMetadata) && pickerMetadata.length > 0}
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
{#each pickerMetadata as item (item.id)}
|
|
||||||
<WorkflowPickerItemCard {item} {isAlbum} onRemove={() => removeItemFromSelection(item.id)} />
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<Button size="small" variant="outline" leadingIcon={mdiPlus} onclick={handlePicker}>
|
|
||||||
{getButtonText()}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Field>
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils';
|
|
||||||
import type { AlbumResponseDto, PersonResponseDto } from '@immich/sdk';
|
|
||||||
import { Card, CardBody, IconButton, Text } from '@immich/ui';
|
|
||||||
import { mdiClose } from '@mdi/js';
|
|
||||||
import { t } from 'svelte-i18n';
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
item: AlbumResponseDto | PersonResponseDto;
|
|
||||||
isAlbum: boolean;
|
|
||||||
onRemove: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
let { item, isAlbum, onRemove }: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Card color="secondary">
|
|
||||||
<CardBody class="flex items-center gap-3">
|
|
||||||
<div class="shrink-0">
|
|
||||||
{#if isAlbum && 'albumThumbnailAssetId' in item}
|
|
||||||
{#if item.albumThumbnailAssetId}
|
|
||||||
<img
|
|
||||||
src={getAssetThumbnailUrl(item.albumThumbnailAssetId)}
|
|
||||||
alt={item.albumName}
|
|
||||||
class="h-12 w-12 rounded-lg object-cover"
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<div class="h-12 w-12 rounded-lg"></div>
|
|
||||||
{/if}
|
|
||||||
{:else if !isAlbum && 'name' in item}
|
|
||||||
<img src={getPeopleThumbnailUrl(item)} alt={item.name} class="h-12 w-12 rounded-full object-cover" />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<Text class="font-semibold truncate">
|
|
||||||
{isAlbum && 'albumName' in item ? item.albumName : 'name' in item ? item.name : ''}
|
|
||||||
</Text>
|
|
||||||
{#if isAlbum && 'assetCount' in item}
|
|
||||||
<Text size="small" color="muted">
|
|
||||||
{$t('items_count', { values: { count: item.assetCount } })}
|
|
||||||
</Text>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<IconButton
|
|
||||||
type="button"
|
|
||||||
onclick={onRemove}
|
|
||||||
class="shrink-0"
|
|
||||||
shape="round"
|
|
||||||
aria-label={$t('remove')}
|
|
||||||
icon={mdiClose}
|
|
||||||
size="small"
|
|
||||||
variant="ghost"
|
|
||||||
color="secondary"
|
|
||||||
/>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import {
|
|
||||||
PluginTriggerType,
|
|
||||||
type PluginActionResponseDto,
|
|
||||||
type PluginFilterResponseDto,
|
|
||||||
type PluginTriggerResponseDto,
|
|
||||||
} from '@immich/sdk';
|
|
||||||
import { Icon, IconButton, Text } from '@immich/ui';
|
|
||||||
import { mdiClose, mdiFilterOutline, mdiFlashOutline, mdiPlayCircleOutline, mdiViewDashboardOutline } from '@mdi/js';
|
|
||||||
import { t } from 'svelte-i18n';
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
trigger: PluginTriggerResponseDto;
|
|
||||||
filters: PluginFilterResponseDto[];
|
|
||||||
actions: PluginActionResponseDto[];
|
|
||||||
};
|
|
||||||
|
|
||||||
let { trigger, filters, actions }: Props = $props();
|
|
||||||
|
|
||||||
const getTriggerName = (triggerType: PluginTriggerType) => {
|
|
||||||
switch (triggerType) {
|
|
||||||
case PluginTriggerType.AssetCreate: {
|
|
||||||
return $t('trigger_asset_uploaded');
|
|
||||||
}
|
|
||||||
case PluginTriggerType.PersonRecognized: {
|
|
||||||
return $t('trigger_person_recognized');
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
return triggerType;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let isOpen = $state(false);
|
|
||||||
let position = $state({ x: 0, y: 0 });
|
|
||||||
let isDragging = $state(false);
|
|
||||||
let dragOffset = $state({ x: 0, y: 0 });
|
|
||||||
let containerEl: HTMLDivElement | undefined = $state();
|
|
||||||
|
|
||||||
const handleMouseDown = (e: MouseEvent) => {
|
|
||||||
if (!containerEl) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
isDragging = true;
|
|
||||||
const rect = containerEl.getBoundingClientRect();
|
|
||||||
dragOffset = {
|
|
||||||
x: e.clientX - rect.left,
|
|
||||||
y: e.clientY - rect.top,
|
|
||||||
};
|
|
||||||
document.addEventListener('mousemove', handleMouseMove);
|
|
||||||
document.addEventListener('mouseup', handleMouseUp);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
|
||||||
if (!isDragging) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
position = {
|
|
||||||
x: e.clientX - dragOffset.x,
|
|
||||||
y: e.clientY - dragOffset.y,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
|
||||||
isDragging = false;
|
|
||||||
document.removeEventListener('mousemove', handleMouseMove);
|
|
||||||
document.removeEventListener('mouseup', handleMouseUp);
|
|
||||||
};
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
// Initialize position to bottom-right on mount
|
|
||||||
if (globalThis.window && position.x === 0 && position.y === 0) {
|
|
||||||
position = {
|
|
||||||
x: globalThis.innerWidth - 280,
|
|
||||||
y: globalThis.innerHeight - 400,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if isOpen}
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
||||||
<div
|
|
||||||
bind:this={containerEl}
|
|
||||||
class="hidden sm:block fixed w-64 hover:cursor-grab select-none"
|
|
||||||
style="left: {position.x}px; top: {position.y}px;"
|
|
||||||
class:cursor-grabbing={isDragging}
|
|
||||||
onmousedown={handleMouseDown}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="rounded-xl border-transparent border-2 hover:shadow-xl hover:border-dashed bg-light-50 shadow-sm p-4 hover:border-light-300 transition-all"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between mb-4 cursor-grab select-none">
|
|
||||||
<Text size="small" class="font-semibold">{$t('workflow_summary')}</Text>
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<IconButton
|
|
||||||
icon={mdiClose}
|
|
||||||
size="small"
|
|
||||||
variant="ghost"
|
|
||||||
color="secondary"
|
|
||||||
title="Close summary"
|
|
||||||
aria-label="Close summary"
|
|
||||||
onclick={(e: MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
isOpen = false;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<!-- Trigger -->
|
|
||||||
<div class="rounded-lg bg-light-100 border p-3">
|
|
||||||
<div class="flex items-center gap-2 mb-1">
|
|
||||||
<Icon icon={mdiFlashOutline} size="18" class="text-primary" />
|
|
||||||
<span class="text-[10px] font-semibold uppercase tracking-wide">{$t('trigger')}</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm truncate pl-5">{getTriggerName(trigger.type)}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Connector -->
|
|
||||||
<div class="flex justify-center">
|
|
||||||
<div class="w-0.5 h-3 bg-light-400"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Filters -->
|
|
||||||
{#if filters.length > 0}
|
|
||||||
<div class="rounded-lg bg-light-100 border p-3">
|
|
||||||
<div class="flex items-center gap-2 mb-2">
|
|
||||||
<Icon icon={mdiFilterOutline} size="18" class="text-warning" />
|
|
||||||
<span class="text-[10px] font-semibold uppercase tracking-wide">{$t('filters')}</span>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1 pl-5">
|
|
||||||
{#each filters as filter, index (index)}
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
class="shrink-0 h-4 w-4 rounded-full bg-light-200 text-[10px] font-medium flex items-center justify-center"
|
|
||||||
>{index + 1}</span
|
|
||||||
>
|
|
||||||
<p class="text-sm truncate">{filter.title}</p>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Connector -->
|
|
||||||
<div class="flex justify-center">
|
|
||||||
<div class="w-0.5 h-3 bg-light-400"></div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Actions -->
|
|
||||||
{#if actions.length > 0}
|
|
||||||
<div class="rounded-lg bg-light-100 border p-3">
|
|
||||||
<div class="flex items-center gap-2 mb-2">
|
|
||||||
<Icon icon={mdiPlayCircleOutline} size="18" class="text-success" />
|
|
||||||
<span class="text-[10px] font-semibold uppercase tracking-wide">{$t('actions')}</span>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1 pl-5">
|
|
||||||
{#each actions as action, index (index)}
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
class="shrink-0 h-4 w-4 rounded-full bg-light-200 text-[10px] font-medium flex items-center justify-center"
|
|
||||||
>{index + 1}</span
|
|
||||||
>
|
|
||||||
<p class="text-sm truncate">{action.title}</p>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="hidden sm:flex fixed right-6 bottom-6 h-14 w-14 items-center justify-center rounded-full bg-primary text-light shadow-lg hover:bg-primary/90 transition-colors"
|
|
||||||
title={$t('workflow_summary')}
|
|
||||||
onclick={() => (isOpen = true)}
|
|
||||||
>
|
|
||||||
<Icon icon={mdiViewDashboardOutline} size="24" />
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { PluginTriggerType, type PluginTriggerResponseDto } from '@immich/sdk';
|
|
||||||
import { Icon, Text } from '@immich/ui';
|
|
||||||
import { mdiFaceRecognition, mdiFileUploadOutline, mdiLightningBolt } from '@mdi/js';
|
|
||||||
import { t } from 'svelte-i18n';
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
trigger: PluginTriggerResponseDto;
|
|
||||||
selected: boolean;
|
|
||||||
onclick: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
let { trigger, selected, onclick }: Props = $props();
|
|
||||||
|
|
||||||
const getTriggerIcon = (triggerType: PluginTriggerType) => {
|
|
||||||
switch (triggerType) {
|
|
||||||
case PluginTriggerType.AssetCreate: {
|
|
||||||
return mdiFileUploadOutline;
|
|
||||||
}
|
|
||||||
case PluginTriggerType.PersonRecognized: {
|
|
||||||
return mdiFaceRecognition;
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
return mdiLightningBolt;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTriggerName = (triggerType: PluginTriggerType) => {
|
|
||||||
switch (triggerType) {
|
|
||||||
case PluginTriggerType.AssetCreate: {
|
|
||||||
return $t('trigger_asset_uploaded');
|
|
||||||
}
|
|
||||||
case PluginTriggerType.PersonRecognized: {
|
|
||||||
return $t('trigger_person_recognized');
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
return triggerType;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTriggerDescription = (triggerType: PluginTriggerType) => {
|
|
||||||
switch (triggerType) {
|
|
||||||
case PluginTriggerType.AssetCreate: {
|
|
||||||
return $t('trigger_asset_uploaded_description');
|
|
||||||
}
|
|
||||||
case PluginTriggerType.PersonRecognized: {
|
|
||||||
return $t('trigger_person_recognized_description');
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
{onclick}
|
|
||||||
class="group rounded-xl p-4 w-full text-left cursor-pointer border-2 {selected
|
|
||||||
? 'border-primary text-primary'
|
|
||||||
: 'border-light-100 hover:border-light-200 text-light-400 hover:text-light-700'}"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div
|
|
||||||
class="rounded-xl p-2 {selected
|
|
||||||
? 'bg-primary text-light'
|
|
||||||
: 'text-light-100 bg-light-300 group-hover:bg-light-500'}"
|
|
||||||
>
|
|
||||||
<Icon icon={getTriggerIcon(trigger.type)} size="24" />
|
|
||||||
</div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<Text class="font-semibold mb-1">{getTriggerName(trigger.type)}</Text>
|
|
||||||
{#if getTriggerDescription(trigger.type)}
|
|
||||||
<Text size="small">{getTriggerDescription(trigger.type)}</Text>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
@@ -55,7 +55,6 @@ export enum AppRoute {
|
|||||||
DUPLICATES = '/utilities/duplicates',
|
DUPLICATES = '/utilities/duplicates',
|
||||||
LARGE_FILES = '/utilities/large-files',
|
LARGE_FILES = '/utilities/large-files',
|
||||||
GEOLOCATION = '/utilities/geolocation',
|
GEOLOCATION = '/utilities/geolocation',
|
||||||
WORKFLOWS = '/utilities/workflows',
|
|
||||||
|
|
||||||
FOLDERS = '/folders',
|
FOLDERS = '/folders',
|
||||||
TAGS = '/tags',
|
TAGS = '/tags',
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import type {
|
|||||||
SharedLinkResponseDto,
|
SharedLinkResponseDto,
|
||||||
SystemConfigDto,
|
SystemConfigDto,
|
||||||
UserAdminResponseDto,
|
UserAdminResponseDto,
|
||||||
WorkflowResponseDto,
|
|
||||||
} from '@immich/sdk';
|
} from '@immich/sdk';
|
||||||
|
|
||||||
export type Events = {
|
export type Events = {
|
||||||
@@ -43,9 +42,6 @@ export type Events = {
|
|||||||
LibraryUpdate: [LibraryResponseDto];
|
LibraryUpdate: [LibraryResponseDto];
|
||||||
LibraryDelete: [{ id: string }];
|
LibraryDelete: [{ id: string }];
|
||||||
|
|
||||||
WorkflowUpdate: [WorkflowResponseDto];
|
|
||||||
WorkflowDelete: [WorkflowResponseDto];
|
|
||||||
|
|
||||||
ReleaseEvent: [ReleaseEvent];
|
ReleaseEvent: [ReleaseEvent];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { PluginActionResponseDto, PluginFilterResponseDto } from '@immich/sdk';
|
|
||||||
import { Modal, ModalBody, Text } from '@immich/ui';
|
|
||||||
import { mdiFilterOutline, mdiPlayCircleOutline } from '@mdi/js';
|
|
||||||
import { t } from 'svelte-i18n';
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
filters: PluginFilterResponseDto[];
|
|
||||||
actions: PluginActionResponseDto[];
|
|
||||||
onClose: (result?: { type: 'filter' | 'action'; item: PluginFilterResponseDto | PluginActionResponseDto }) => void;
|
|
||||||
type?: 'filter' | 'action';
|
|
||||||
};
|
|
||||||
|
|
||||||
let { filters, actions, onClose, type }: Props = $props();
|
|
||||||
|
|
||||||
type StepType = 'filter' | 'action';
|
|
||||||
|
|
||||||
const handleSelect = (type: StepType, item: PluginFilterResponseDto | PluginActionResponseDto) => {
|
|
||||||
onClose({ type, item });
|
|
||||||
};
|
|
||||||
|
|
||||||
const getModalTitle = () => {
|
|
||||||
if (type === 'filter') {
|
|
||||||
return $t('add_filter');
|
|
||||||
} else if (type === 'action') {
|
|
||||||
return $t('add_action');
|
|
||||||
} else {
|
|
||||||
return $t('add_workflow_step');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getModalIcon = () => {
|
|
||||||
if (type === 'filter') {
|
|
||||||
return mdiFilterOutline;
|
|
||||||
} else if (type === 'action') {
|
|
||||||
return mdiPlayCircleOutline;
|
|
||||||
} else {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#snippet stepButton(title: string, description?: string, onclick?: () => void)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
{onclick}
|
|
||||||
class="flex items-start gap-3 p-3 rounded-lg text-left bg-light-100 hover:border-primary border text-dark"
|
|
||||||
>
|
|
||||||
<div class="flex-1">
|
|
||||||
<Text color="primary" class="font-medium">{title}</Text>
|
|
||||||
{#if description}
|
|
||||||
<Text size="small" class="mt-1">{description}</Text>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
<Modal title={getModalTitle()} icon={getModalIcon()} onClose={() => onClose()}>
|
|
||||||
<ModalBody>
|
|
||||||
<div class="space-y-6">
|
|
||||||
<!-- Filters Section -->
|
|
||||||
{#if filters.length > 0 && (!type || type === 'filter')}
|
|
||||||
<div class="grid grid-cols-1 gap-2">
|
|
||||||
{#each filters as filter (filter.id)}
|
|
||||||
{@render stepButton(filter.title, filter.description, () => handleSelect('filter', filter))}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Actions Section -->
|
|
||||||
{#if actions.length > 0 && (!type || type === 'action')}
|
|
||||||
<div class="grid grid-cols-1 gap-2">
|
|
||||||
{#each actions as action (action.id)}
|
|
||||||
{@render stepButton(action.title, action.description, () => handleSelect('action', action))}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</ModalBody>
|
|
||||||
</Modal>
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
|
|
||||||
import SearchBar from '$lib/elements/SearchBar.svelte';
|
|
||||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
|
||||||
import { getAllPeople, type PersonResponseDto } from '@immich/sdk';
|
|
||||||
import { Button, HStack, LoadingSpinner, Modal, ModalBody, ModalFooter } from '@immich/ui';
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { t } from 'svelte-i18n';
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
multiple?: boolean;
|
|
||||||
excludedIds?: string[];
|
|
||||||
onClose: (people?: PersonResponseDto[]) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
let { multiple = false, excludedIds = [], onClose }: Props = $props();
|
|
||||||
|
|
||||||
let people: PersonResponseDto[] = $state([]);
|
|
||||||
let loading = $state(true);
|
|
||||||
let searchName = $state('');
|
|
||||||
let selectedPeople: PersonResponseDto[] = $state([]);
|
|
||||||
|
|
||||||
const filteredPeople = $derived(
|
|
||||||
people
|
|
||||||
.filter((person) => !excludedIds.includes(person.id))
|
|
||||||
.filter((person) => !searchName || person.name.toLowerCase().includes(searchName.toLowerCase())),
|
|
||||||
);
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
try {
|
|
||||||
loading = true;
|
|
||||||
const result = await getAllPeople({ withHidden: false });
|
|
||||||
people = result.people;
|
|
||||||
loading = false;
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error, $t('get_people_error'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const togglePerson = (person: PersonResponseDto) => {
|
|
||||||
if (multiple) {
|
|
||||||
const index = selectedPeople.findIndex((p) => p.id === person.id);
|
|
||||||
selectedPeople = index === -1 ? [...selectedPeople, person] : selectedPeople.filter((p) => p.id !== person.id);
|
|
||||||
} else {
|
|
||||||
onClose([person]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
if (selectedPeople.length > 0) {
|
|
||||||
onClose(selectedPeople);
|
|
||||||
} else {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Modal title={multiple ? $t('select_people') : $t('select_person')} {onClose} size="small">
|
|
||||||
<ModalBody>
|
|
||||||
<div class="flex flex-col gap-4">
|
|
||||||
<SearchBar bind:name={searchName} placeholder={$t('search_people')} showLoadingSpinner={false} />
|
|
||||||
|
|
||||||
<div class="immich-scrollbar max-h-96 overflow-y-auto">
|
|
||||||
{#if loading}
|
|
||||||
<div class="flex justify-center p-8">
|
|
||||||
<LoadingSpinner />
|
|
||||||
</div>
|
|
||||||
{:else if filteredPeople.length > 0}
|
|
||||||
<div class="grid grid-cols-3 gap-4 p-2">
|
|
||||||
{#each filteredPeople as person (person.id)}
|
|
||||||
{@const isSelected = selectedPeople.some((p) => p.id === person.id)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => togglePerson(person)}
|
|
||||||
class="flex flex-col items-center gap-2 rounded-xl p-2 transition-all hover:bg-subtle {isSelected
|
|
||||||
? 'bg-primary/10 ring-2 ring-primary'
|
|
||||||
: ''}"
|
|
||||||
>
|
|
||||||
<ImageThumbnail
|
|
||||||
circle
|
|
||||||
shadow
|
|
||||||
url={getPeopleThumbnailUrl(person)}
|
|
||||||
altText={person.name}
|
|
||||||
widthStyle="100%"
|
|
||||||
/>
|
|
||||||
<p class="line-clamp-2 text-center text-sm font-medium">{person.name}</p>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<p class="py-8 text-center text-sm text-gray-500">{$t('no_people_found')}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ModalBody>
|
|
||||||
|
|
||||||
{#if multiple}
|
|
||||||
<ModalFooter>
|
|
||||||
<HStack fullWidth gap={4}>
|
|
||||||
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
|
|
||||||
<Button shape="round" fullWidth onclick={handleSubmit} disabled={selectedPeople.length === 0}>
|
|
||||||
{$t('select_count', { values: { count: selectedPeople.length } })}
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
</ModalFooter>
|
|
||||||
{/if}
|
|
||||||
</Modal>
|
|
||||||
@@ -241,7 +241,7 @@ export const asQueueItem = ($t: MessageFormatter, queue: { name: QueueName }): Q
|
|||||||
},
|
},
|
||||||
[QueueName.Workflow]: {
|
[QueueName.Workflow]: {
|
||||||
icon: mdiStateMachine,
|
icon: mdiStateMachine,
|
||||||
title: $t('workflows'),
|
title: $t('workflow'),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,451 +0,0 @@
|
|||||||
import { goto } from '$app/navigation';
|
|
||||||
import { AppRoute } from '$lib/constants';
|
|
||||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
|
||||||
import { getFormatter } from '$lib/utils/i18n';
|
|
||||||
import {
|
|
||||||
createWorkflow,
|
|
||||||
deleteWorkflow,
|
|
||||||
getAlbumInfo,
|
|
||||||
getPerson,
|
|
||||||
PluginTriggerType,
|
|
||||||
updateWorkflow,
|
|
||||||
type AlbumResponseDto,
|
|
||||||
type PersonResponseDto,
|
|
||||||
type PluginActionResponseDto,
|
|
||||||
type PluginContextType,
|
|
||||||
type PluginFilterResponseDto,
|
|
||||||
type PluginTriggerResponseDto,
|
|
||||||
type WorkflowActionItemDto,
|
|
||||||
type WorkflowFilterItemDto,
|
|
||||||
type WorkflowResponseDto,
|
|
||||||
type WorkflowUpdateDto,
|
|
||||||
} from '@immich/sdk';
|
|
||||||
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
|
|
||||||
import { mdiCodeJson, mdiDelete, mdiPause, mdiPencil, mdiPlay } from '@mdi/js';
|
|
||||||
import type { MessageFormatter } from 'svelte-i18n';
|
|
||||||
|
|
||||||
export type PickerSubType = 'album-picker' | 'people-picker';
|
|
||||||
export type PickerMetadata = AlbumResponseDto | PersonResponseDto | AlbumResponseDto[] | PersonResponseDto[];
|
|
||||||
|
|
||||||
export interface WorkflowPayload {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
enabled: boolean;
|
|
||||||
triggerType: string;
|
|
||||||
filters: Record<string, unknown>[];
|
|
||||||
actions: Record<string, unknown>[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get filters that support the given context
|
|
||||||
*/
|
|
||||||
export const getFiltersByContext = (
|
|
||||||
availableFilters: PluginFilterResponseDto[],
|
|
||||||
context: PluginContextType,
|
|
||||||
): PluginFilterResponseDto[] => {
|
|
||||||
return availableFilters.filter((filter) => filter.supportedContexts.includes(context));
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get actions that support the given context
|
|
||||||
*/
|
|
||||||
export const getActionsByContext = (
|
|
||||||
availableActions: PluginActionResponseDto[],
|
|
||||||
context: PluginContextType,
|
|
||||||
): PluginActionResponseDto[] => {
|
|
||||||
return availableActions.filter((action) => action.supportedContexts.includes(context));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const remapConfigsOnReorder = (
|
|
||||||
configs: Record<string, unknown>,
|
|
||||||
prefix: 'filter' | 'action',
|
|
||||||
fromIndex: number,
|
|
||||||
toIndex: number,
|
|
||||||
totalCount: number,
|
|
||||||
): Record<string, unknown> => {
|
|
||||||
const newConfigs: Record<string, unknown> = {};
|
|
||||||
|
|
||||||
// Create an array of configs in order
|
|
||||||
const configArray: unknown[] = [];
|
|
||||||
for (let i = 0; i < totalCount; i++) {
|
|
||||||
configArray.push(configs[`${prefix}_${i}`] ?? {});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move the item from fromIndex to toIndex
|
|
||||||
const [movedItem] = configArray.splice(fromIndex, 1);
|
|
||||||
configArray.splice(toIndex, 0, movedItem);
|
|
||||||
|
|
||||||
// Rebuild the configs object with new indices
|
|
||||||
for (let i = 0; i < configArray.length; i++) {
|
|
||||||
newConfigs[`${prefix}_${i}`] = configArray[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
return newConfigs;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remap configs when an item is removed
|
|
||||||
* Shifts all configs after the removed index down by one
|
|
||||||
*/
|
|
||||||
export const remapConfigsOnRemove = (
|
|
||||||
configs: Record<string, unknown>,
|
|
||||||
prefix: 'filter' | 'action',
|
|
||||||
removedIndex: number,
|
|
||||||
totalCount: number,
|
|
||||||
): Record<string, unknown> => {
|
|
||||||
const newConfigs: Record<string, unknown> = {};
|
|
||||||
|
|
||||||
let newIndex = 0;
|
|
||||||
for (let i = 0; i < totalCount; i++) {
|
|
||||||
if (i !== removedIndex) {
|
|
||||||
newConfigs[`${prefix}_${newIndex}`] = configs[`${prefix}_${i}`] ?? {};
|
|
||||||
newIndex++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return newConfigs;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const initializeConfigs = (
|
|
||||||
type: 'action' | 'filter',
|
|
||||||
workflow: WorkflowResponseDto,
|
|
||||||
): Record<string, unknown> => {
|
|
||||||
const configs: Record<string, unknown> = {};
|
|
||||||
|
|
||||||
if (workflow.filters && type == 'filter') {
|
|
||||||
for (const [index, workflowFilter] of workflow.filters.entries()) {
|
|
||||||
configs[`filter_${index}`] = workflowFilter.filterConfig ?? {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (workflow.actions && type == 'action') {
|
|
||||||
for (const [index, workflowAction] of workflow.actions.entries()) {
|
|
||||||
configs[`action_${index}`] = workflowAction.actionConfig ?? {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return configs;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build workflow payload from current state
|
|
||||||
* Uses index-based keys to support multiple filters/actions of the same type
|
|
||||||
*/
|
|
||||||
export const buildWorkflowPayload = (
|
|
||||||
name: string,
|
|
||||||
description: string,
|
|
||||||
enabled: boolean,
|
|
||||||
triggerType: string,
|
|
||||||
orderedFilters: PluginFilterResponseDto[],
|
|
||||||
orderedActions: PluginActionResponseDto[],
|
|
||||||
filterConfigs: Record<string, unknown>,
|
|
||||||
actionConfigs: Record<string, unknown>,
|
|
||||||
): WorkflowPayload => {
|
|
||||||
const filters = orderedFilters.map((filter, index) => ({
|
|
||||||
[filter.methodName]: filterConfigs[`filter_${index}`] ?? {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const actions = orderedActions.map((action, index) => ({
|
|
||||||
[action.methodName]: actionConfigs[`action_${index}`] ?? {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
enabled,
|
|
||||||
triggerType,
|
|
||||||
filters,
|
|
||||||
actions,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const parseWorkflowJson = (
|
|
||||||
jsonString: string,
|
|
||||||
availableTriggers: PluginTriggerResponseDto[],
|
|
||||||
availableFilters: PluginFilterResponseDto[],
|
|
||||||
availableActions: PluginActionResponseDto[],
|
|
||||||
): {
|
|
||||||
success: boolean;
|
|
||||||
error?: string;
|
|
||||||
data?: {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
enabled: boolean;
|
|
||||||
trigger?: PluginTriggerResponseDto;
|
|
||||||
filters: PluginFilterResponseDto[];
|
|
||||||
actions: PluginActionResponseDto[];
|
|
||||||
filterConfigs: Record<string, unknown>;
|
|
||||||
actionConfigs: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
} => {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(jsonString);
|
|
||||||
|
|
||||||
const trigger = availableTriggers.find((t) => t.type === parsed.triggerType);
|
|
||||||
|
|
||||||
const filters: PluginFilterResponseDto[] = [];
|
|
||||||
const filterConfigs: Record<string, unknown> = {};
|
|
||||||
if (Array.isArray(parsed.filters)) {
|
|
||||||
for (const [index, filterObj] of parsed.filters.entries()) {
|
|
||||||
const methodName = Object.keys(filterObj)[0];
|
|
||||||
const filter = availableFilters.find((f) => f.methodName === methodName);
|
|
||||||
if (filter) {
|
|
||||||
filters.push(filter);
|
|
||||||
filterConfigs[`filter_${index}`] = (filterObj as Record<string, unknown>)[methodName];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const actions: PluginActionResponseDto[] = [];
|
|
||||||
const actionConfigs: Record<string, unknown> = {};
|
|
||||||
if (Array.isArray(parsed.actions)) {
|
|
||||||
for (const [index, actionObj] of parsed.actions.entries()) {
|
|
||||||
const methodName = Object.keys(actionObj)[0];
|
|
||||||
const action = availableActions.find((a) => a.methodName === methodName);
|
|
||||||
if (action) {
|
|
||||||
actions.push(action);
|
|
||||||
actionConfigs[`action_${index}`] = (actionObj as Record<string, unknown>)[methodName];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
name: parsed.name ?? '',
|
|
||||||
description: parsed.description ?? '',
|
|
||||||
enabled: parsed.enabled ?? false,
|
|
||||||
trigger,
|
|
||||||
filters,
|
|
||||||
actions,
|
|
||||||
filterConfigs,
|
|
||||||
actionConfigs,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Invalid JSON',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const hasWorkflowChanged = (
|
|
||||||
previousWorkflow: WorkflowResponseDto,
|
|
||||||
enabled: boolean,
|
|
||||||
name: string,
|
|
||||||
description: string,
|
|
||||||
triggerType: string,
|
|
||||||
orderedFilters: PluginFilterResponseDto[],
|
|
||||||
orderedActions: PluginActionResponseDto[],
|
|
||||||
filterConfigs: Record<string, unknown>,
|
|
||||||
actionConfigs: Record<string, unknown>,
|
|
||||||
): boolean => {
|
|
||||||
if (enabled !== previousWorkflow.enabled) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (name !== (previousWorkflow.name ?? '') || description !== (previousWorkflow.description ?? '')) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (triggerType !== previousWorkflow.triggerType) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const previousFilterIds = previousWorkflow.filters?.map((f) => f.pluginFilterId) ?? [];
|
|
||||||
const currentFilterIds = orderedFilters.map((f) => f.id);
|
|
||||||
if (JSON.stringify(previousFilterIds) !== JSON.stringify(currentFilterIds)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const previousActionIds = previousWorkflow.actions?.map((a) => a.pluginActionId) ?? [];
|
|
||||||
const currentActionIds = orderedActions.map((a) => a.id);
|
|
||||||
if (JSON.stringify(previousActionIds) !== JSON.stringify(currentActionIds)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const previousFilterConfigs: Record<string, unknown> = {};
|
|
||||||
for (const [index, wf] of (previousWorkflow.filters ?? []).entries()) {
|
|
||||||
previousFilterConfigs[`filter_${index}`] = wf.filterConfig ?? {};
|
|
||||||
}
|
|
||||||
if (JSON.stringify(previousFilterConfigs) !== JSON.stringify(filterConfigs)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const previousActionConfigs: Record<string, unknown> = {};
|
|
||||||
for (const [index, wa] of (previousWorkflow.actions ?? []).entries()) {
|
|
||||||
previousActionConfigs[`action_${index}`] = wa.actionConfig ?? {};
|
|
||||||
}
|
|
||||||
if (JSON.stringify(previousActionConfigs) !== JSON.stringify(actionConfigs)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const handleUpdateWorkflow = async (
|
|
||||||
workflowId: string,
|
|
||||||
name: string,
|
|
||||||
description: string,
|
|
||||||
enabled: boolean,
|
|
||||||
triggerType: PluginTriggerType,
|
|
||||||
orderedFilters: PluginFilterResponseDto[],
|
|
||||||
orderedActions: PluginActionResponseDto[],
|
|
||||||
filterConfigs: Record<string, unknown>,
|
|
||||||
actionConfigs: Record<string, unknown>,
|
|
||||||
): Promise<WorkflowResponseDto> => {
|
|
||||||
const filters = orderedFilters.map((filter, index) => ({
|
|
||||||
pluginFilterId: filter.id,
|
|
||||||
filterConfig: filterConfigs[`filter_${index}`] ?? {},
|
|
||||||
})) as WorkflowFilterItemDto[];
|
|
||||||
|
|
||||||
const actions = orderedActions.map((action, index) => ({
|
|
||||||
pluginActionId: action.id,
|
|
||||||
actionConfig: actionConfigs[`action_${index}`] ?? {},
|
|
||||||
})) as WorkflowActionItemDto[];
|
|
||||||
|
|
||||||
const updateDto: WorkflowUpdateDto = {
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
enabled,
|
|
||||||
filters,
|
|
||||||
actions,
|
|
||||||
triggerType,
|
|
||||||
};
|
|
||||||
|
|
||||||
return updateWorkflow({ id: workflowId, workflowUpdateDto: updateDto });
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getWorkflowActions = ($t: MessageFormatter, workflow: WorkflowResponseDto) => {
|
|
||||||
const ToggleEnabled: ActionItem = {
|
|
||||||
title: workflow.enabled ? $t('disable') : $t('enable'),
|
|
||||||
icon: workflow.enabled ? mdiPause : mdiPlay,
|
|
||||||
color: workflow.enabled ? 'danger' : 'primary',
|
|
||||||
onAction: async () => {
|
|
||||||
await handleToggleWorkflowEnabled(workflow);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const Edit: ActionItem = {
|
|
||||||
title: $t('edit'),
|
|
||||||
icon: mdiPencil,
|
|
||||||
onAction: () => handleNavigateToWorkflow(workflow),
|
|
||||||
};
|
|
||||||
|
|
||||||
const Delete: ActionItem = {
|
|
||||||
title: $t('delete'),
|
|
||||||
icon: mdiDelete,
|
|
||||||
color: 'danger',
|
|
||||||
onAction: async () => {
|
|
||||||
await handleDeleteWorkflow(workflow);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return { ToggleEnabled, Edit, Delete };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getWorkflowShowSchemaAction = (
|
|
||||||
$t: MessageFormatter,
|
|
||||||
isExpanded: boolean,
|
|
||||||
onToggle: () => void,
|
|
||||||
): ActionItem => ({
|
|
||||||
title: isExpanded ? $t('hide_schema') : $t('show_schema'),
|
|
||||||
icon: mdiCodeJson,
|
|
||||||
onAction: onToggle,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const handleCreateWorkflow = async (): Promise<WorkflowResponseDto | undefined> => {
|
|
||||||
const $t = await getFormatter();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const workflow = await createWorkflow({
|
|
||||||
workflowCreateDto: {
|
|
||||||
name: $t('untitled_workflow'),
|
|
||||||
triggerType: PluginTriggerType.AssetCreate,
|
|
||||||
filters: [],
|
|
||||||
actions: [],
|
|
||||||
enabled: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await goto(`${AppRoute.WORKFLOWS}/${workflow.id}`);
|
|
||||||
return workflow;
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error, $t('errors.unable_to_create'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const handleToggleWorkflowEnabled = async (
|
|
||||||
workflow: WorkflowResponseDto,
|
|
||||||
): Promise<WorkflowResponseDto | undefined> => {
|
|
||||||
const $t = await getFormatter();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const updated = await updateWorkflow({
|
|
||||||
id: workflow.id,
|
|
||||||
workflowUpdateDto: { enabled: !workflow.enabled },
|
|
||||||
});
|
|
||||||
|
|
||||||
eventManager.emit('WorkflowUpdate', updated);
|
|
||||||
toastManager.success($t('workflow_updated'));
|
|
||||||
return updated;
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error, $t('errors.unable_to_update_workflow'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const handleDeleteWorkflow = async (workflow: WorkflowResponseDto): Promise<boolean> => {
|
|
||||||
const $t = await getFormatter();
|
|
||||||
|
|
||||||
const confirmed = await modalManager.showDialog({
|
|
||||||
prompt: $t('workflow_delete_prompt'),
|
|
||||||
confirmColor: 'danger',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!confirmed) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await deleteWorkflow({ id: workflow.id });
|
|
||||||
eventManager.emit('WorkflowDelete', workflow);
|
|
||||||
toastManager.success($t('workflow_deleted'));
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error, $t('errors.unable_to_delete_workflow'));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const handleNavigateToWorkflow = async (workflow: WorkflowResponseDto): Promise<void> => {
|
|
||||||
await goto(`${AppRoute.WORKFLOWS}/${workflow.id}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const fetchPickerMetadata = async (
|
|
||||||
value: string | string[] | undefined,
|
|
||||||
subType: PickerSubType,
|
|
||||||
): Promise<PickerMetadata | undefined> => {
|
|
||||||
if (!value) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isAlbum = subType === 'album-picker';
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (Array.isArray(value) && value.length > 0) {
|
|
||||||
// Multiple selection
|
|
||||||
return isAlbum
|
|
||||||
? await Promise.all(value.map((id) => getAlbumInfo({ id })))
|
|
||||||
: await Promise.all(value.map((id) => getPerson({ id })));
|
|
||||||
} else if (typeof value === 'string' && value) {
|
|
||||||
// Single selection
|
|
||||||
return isAlbum ? await getAlbumInfo({ id: value }) : await getPerson({ id: value });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to fetch picker metadata:`, error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
@@ -162,7 +162,7 @@ export const getQueueName = derived(t, ($t) => {
|
|||||||
[QueueName.Notifications]: $t('notifications'),
|
[QueueName.Notifications]: $t('notifications'),
|
||||||
[QueueName.BackupDatabase]: $t('admin.backup_database'),
|
[QueueName.BackupDatabase]: $t('admin.backup_database'),
|
||||||
[QueueName.Ocr]: $t('admin.machine_learning_ocr'),
|
[QueueName.Ocr]: $t('admin.machine_learning_ocr'),
|
||||||
[QueueName.Workflow]: $t('workflows'),
|
[QueueName.Workflow]: $t('workflow'),
|
||||||
};
|
};
|
||||||
|
|
||||||
return names[name];
|
return names[name];
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
export type ComponentType = 'select' | 'multiselect' | 'text' | 'switch' | 'checkbox';
|
|
||||||
|
|
||||||
export interface ComponentConfig {
|
|
||||||
type: ComponentType;
|
|
||||||
label?: string;
|
|
||||||
description?: string;
|
|
||||||
defaultValue?: unknown;
|
|
||||||
required?: boolean;
|
|
||||||
options?: Array<{ label: string; value: string | number | boolean }>;
|
|
||||||
placeholder?: string;
|
|
||||||
subType?: string;
|
|
||||||
title?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface JSONSchemaProperty {
|
|
||||||
type?: string;
|
|
||||||
description?: string;
|
|
||||||
default?: unknown;
|
|
||||||
enum?: unknown[];
|
|
||||||
items?: JSONSchemaProperty;
|
|
||||||
subType?: string;
|
|
||||||
title?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface JSONSchema {
|
|
||||||
type?: string;
|
|
||||||
properties?: Record<string, JSONSchemaProperty>;
|
|
||||||
required?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getComponentDefaultValue = (component: ComponentConfig): unknown => {
|
|
||||||
if (component.defaultValue !== undefined) {
|
|
||||||
return component.defaultValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (component.type === 'multiselect' || (component.type === 'text' && component.subType === 'people-picker')) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (component.type === 'switch') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getComponentFromSchema = (schema: object | null): Record<string, ComponentConfig> | null => {
|
|
||||||
if (!schema || !isJSONSchema(schema) || !schema.properties) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const components: Record<string, ComponentConfig> = {};
|
|
||||||
const requiredFields = schema.required || [];
|
|
||||||
|
|
||||||
for (const [propertyName, property] of Object.entries(schema.properties)) {
|
|
||||||
const config = getComponentForProperty(property, propertyName);
|
|
||||||
if (config) {
|
|
||||||
config.required = requiredFields.includes(propertyName);
|
|
||||||
components[propertyName] = config;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.keys(components).length > 0 ? components : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
function isJSONSchema(obj: object): obj is JSONSchema {
|
|
||||||
return 'properties' in obj || 'type' in obj;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getComponentForProperty(property: JSONSchemaProperty, propertyName: string): ComponentConfig | null {
|
|
||||||
const { type, title, enum: enumValues, description, default: defaultValue, items } = property;
|
|
||||||
|
|
||||||
const config: ComponentConfig = {
|
|
||||||
type: 'text',
|
|
||||||
label: formatLabel(propertyName),
|
|
||||||
description,
|
|
||||||
defaultValue,
|
|
||||||
title,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (enumValues && enumValues.length > 0) {
|
|
||||||
config.type = 'select';
|
|
||||||
config.options = enumValues.map((value: unknown) => ({
|
|
||||||
label: formatLabel(String(value)),
|
|
||||||
value: value as string | number | boolean,
|
|
||||||
}));
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === 'array' && items?.enum && items.enum.length > 0) {
|
|
||||||
config.type = 'multiselect';
|
|
||||||
config.subType = items.subType;
|
|
||||||
config.options = items.enum.map((value: unknown) => ({
|
|
||||||
label: formatLabel(String(value)),
|
|
||||||
value: value as string | number | boolean,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === 'boolean') {
|
|
||||||
config.type = 'switch';
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === 'string') {
|
|
||||||
config.type = 'text';
|
|
||||||
config.subType = property.subType;
|
|
||||||
config.placeholder = description;
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === 'array') {
|
|
||||||
config.type = 'multiselect';
|
|
||||||
config.subType = property.subType;
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatLabel(propertyName: string): string {
|
|
||||||
return propertyName
|
|
||||||
.replaceAll(/([A-Z])/g, ' $1')
|
|
||||||
.replaceAll('_', ' ')
|
|
||||||
.replace(/^./, (str) => str.toUpperCase())
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
@@ -1,281 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import emptyWorkflows from '$lib/assets/empty-workflows.svg';
|
|
||||||
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
|
||||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
|
||||||
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
|
|
||||||
import {
|
|
||||||
getWorkflowActions,
|
|
||||||
getWorkflowShowSchemaAction,
|
|
||||||
handleCreateWorkflow,
|
|
||||||
type WorkflowPayload,
|
|
||||||
} from '$lib/services/workflow.service';
|
|
||||||
import type { PluginFilterResponseDto, WorkflowResponseDto } from '@immich/sdk';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
CardBody,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
CodeBlock,
|
|
||||||
HStack,
|
|
||||||
Icon,
|
|
||||||
IconButton,
|
|
||||||
MenuItemType,
|
|
||||||
menuManager,
|
|
||||||
Text,
|
|
||||||
VStack,
|
|
||||||
} from '@immich/ui';
|
|
||||||
import { mdiClose, mdiDotsVertical, mdiPlus } from '@mdi/js';
|
|
||||||
import { t } from 'svelte-i18n';
|
|
||||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
|
||||||
import type { PageData } from './$types';
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
data: PageData;
|
|
||||||
};
|
|
||||||
|
|
||||||
let { data }: Props = $props();
|
|
||||||
|
|
||||||
let workflows = $state<WorkflowResponseDto[]>(data.workflows);
|
|
||||||
|
|
||||||
const expandedWorkflows = new SvelteSet<string>();
|
|
||||||
|
|
||||||
const pluginFilterLookup = new SvelteMap<string, PluginFilterResponseDto>();
|
|
||||||
const pluginActionLookup = new SvelteMap<string, PluginFilterResponseDto>();
|
|
||||||
|
|
||||||
for (const plugin of data.plugins) {
|
|
||||||
for (const filter of plugin.filters ?? []) {
|
|
||||||
pluginFilterLookup.set(filter.id, { ...filter });
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const action of plugin.actions ?? []) {
|
|
||||||
pluginActionLookup.set(action.id, { ...action });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleShowingSchema = (id: string) => {
|
|
||||||
if (expandedWorkflows.has(id)) {
|
|
||||||
expandedWorkflows.delete(id);
|
|
||||||
} else {
|
|
||||||
expandedWorkflows.add(id);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const constructPayload = (workflow: WorkflowResponseDto): WorkflowPayload => {
|
|
||||||
const orderedFilters = [...(workflow.filters ?? [])].sort((a, b) => a.order - b.order);
|
|
||||||
const orderedActions = [...(workflow.actions ?? [])].sort((a, b) => a.order - b.order);
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: workflow.name ?? '',
|
|
||||||
description: workflow.description ?? '',
|
|
||||||
enabled: workflow.enabled,
|
|
||||||
triggerType: workflow.triggerType,
|
|
||||||
filters: orderedFilters.map((filter) => {
|
|
||||||
const meta = pluginFilterLookup.get(filter.pluginFilterId);
|
|
||||||
const key = meta?.methodName ?? filter.pluginFilterId;
|
|
||||||
return {
|
|
||||||
[key]: filter.filterConfig ?? {},
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
actions: orderedActions.map((action) => {
|
|
||||||
const meta = pluginActionLookup.get(action.pluginActionId);
|
|
||||||
const key = meta?.methodName ?? action.pluginActionId;
|
|
||||||
return {
|
|
||||||
[key]: action.actionConfig ?? {},
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const getJson = (workflow: WorkflowResponseDto) => JSON.stringify(constructPayload(workflow), null, 2);
|
|
||||||
|
|
||||||
const onWorkflowUpdate = (updatedWorkflow: WorkflowResponseDto) => {
|
|
||||||
workflows = workflows.map((currentWorkflow) =>
|
|
||||||
currentWorkflow.id === updatedWorkflow.id ? updatedWorkflow : currentWorkflow,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onWorkflowDelete = (deletedWorkflow: WorkflowResponseDto) => {
|
|
||||||
workflows = workflows.filter((currentWorkflow) => currentWorkflow.id !== deletedWorkflow.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getFilterLabel = (filterId: string) => {
|
|
||||||
const meta = pluginFilterLookup.get(filterId);
|
|
||||||
return meta?.title ?? $t('filter');
|
|
||||||
};
|
|
||||||
|
|
||||||
const getActionLabel = (actionId: string) => {
|
|
||||||
const meta = pluginActionLookup.get(actionId);
|
|
||||||
return meta?.title ?? $t('action');
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTriggerLabel = (triggerType: string) => {
|
|
||||||
const labels: Record<string, string> = {
|
|
||||||
AssetCreate: $t('asset_created'),
|
|
||||||
PersonRecognized: $t('person_recognized'),
|
|
||||||
};
|
|
||||||
return labels[triggerType] || triggerType;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTimestamp = (createdAt: string) =>
|
|
||||||
new Intl.DateTimeFormat(undefined, {
|
|
||||||
dateStyle: 'medium',
|
|
||||||
timeStyle: 'short',
|
|
||||||
}).format(new Date(createdAt));
|
|
||||||
|
|
||||||
const showWorkflowMenu = (event: MouseEvent, workflow: WorkflowResponseDto) => {
|
|
||||||
const { ToggleEnabled, Edit, Delete } = getWorkflowActions($t, workflow);
|
|
||||||
void menuManager.show({
|
|
||||||
target: event.currentTarget as HTMLElement,
|
|
||||||
position: 'top-left',
|
|
||||||
items: [
|
|
||||||
ToggleEnabled,
|
|
||||||
Edit,
|
|
||||||
getWorkflowShowSchemaAction($t, expandedWorkflows.has(workflow.id), () => toggleShowingSchema(workflow.id)),
|
|
||||||
MenuItemType.Divider,
|
|
||||||
Delete,
|
|
||||||
],
|
|
||||||
});
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<OnEvents {onWorkflowUpdate} {onWorkflowDelete} />
|
|
||||||
|
|
||||||
{#snippet chipItem(title: string)}
|
|
||||||
<span class="rounded-xl border border-gray-200/80 px-3 py-1.5 text-sm dark:border-gray-600 bg-light">
|
|
||||||
<span class="font-medium text-dark">{title}</span>
|
|
||||||
</span>
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
<UserPageLayout title={data.meta.title} scrollbar={false}>
|
|
||||||
{#snippet buttons()}
|
|
||||||
<HStack gap={1}>
|
|
||||||
<Button size="small" variant="ghost" color="secondary" onclick={handleCreateWorkflow}>
|
|
||||||
<Icon icon={mdiPlus} size="18" />
|
|
||||||
{$t('create_workflow')}
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
<section class="flex place-content-center sm:mx-4">
|
|
||||||
<section class="w-full pb-28 sm:w-5/6 md:w-4xl">
|
|
||||||
{#if workflows.length === 0}
|
|
||||||
<EmptyPlaceholder
|
|
||||||
title={$t('create_first_workflow')}
|
|
||||||
text={$t('workflows_help_text')}
|
|
||||||
onClick={handleCreateWorkflow}
|
|
||||||
src={emptyWorkflows}
|
|
||||||
class="mt-10 mx-auto"
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<div class="my-6 grid gap-6">
|
|
||||||
{#each workflows as workflow (workflow.id)}
|
|
||||||
<Card class="border border-light-200">
|
|
||||||
<CardHeader
|
|
||||||
class={`flex flex-row px-8 py-6 gap-4 sm:items-center sm:gap-6 ${
|
|
||||||
workflow.enabled
|
|
||||||
? 'bg-linear-to-r from-green-50 to-white dark:from-green-800/50 dark:to-green-950/45'
|
|
||||||
: 'bg-neutral-50 dark:bg-neutral-900'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<span
|
|
||||||
class="rounded-full {workflow.enabled ? 'h-3 w-3 bg-success' : 'h-3 w-3 rounded-full bg-muted'}"
|
|
||||||
></span>
|
|
||||||
<CardTitle>{workflow.name}</CardTitle>
|
|
||||||
</div>
|
|
||||||
<CardDescription class="mt-1 text-sm">
|
|
||||||
{workflow.description || $t('workflows_help_text')}
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<div class="text-right hidden sm:block">
|
|
||||||
<Text size="tiny">{$t('created_at')}</Text>
|
|
||||||
<Text size="small" class="font-medium">
|
|
||||||
{formatTimestamp(workflow.createdAt)}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
<IconButton
|
|
||||||
shape="round"
|
|
||||||
variant="ghost"
|
|
||||||
color="secondary"
|
|
||||||
icon={mdiDotsVertical}
|
|
||||||
aria-label={$t('menu')}
|
|
||||||
onclick={(event: MouseEvent) => showWorkflowMenu(event, workflow)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardBody class="space-y-6">
|
|
||||||
<div class="grid gap-4 md:grid-cols-3">
|
|
||||||
<!-- Trigger Section -->
|
|
||||||
<div class="rounded-2xl border p-4 bg-light-50 border-light-200">
|
|
||||||
<div class="mb-3">
|
|
||||||
<Text class="text-xs font-semibold uppercase tracking-widest" color="muted">{$t('trigger')}</Text>
|
|
||||||
</div>
|
|
||||||
{@render chipItem(getTriggerLabel(workflow.triggerType))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Filters Section -->
|
|
||||||
<div class="rounded-2xl border p-4 bg-light-50 border-light-200">
|
|
||||||
<div class="mb-3">
|
|
||||||
<Text class="text-xs font-semibold uppercase tracking-widest" color="muted">{$t('filters')}</Text>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
{#if workflow.filters.length === 0}
|
|
||||||
<span class="text-sm text-light-600">
|
|
||||||
{$t('no_filters_added')}
|
|
||||||
</span>
|
|
||||||
{:else}
|
|
||||||
{#each workflow.filters as workflowFilter (workflowFilter.id)}
|
|
||||||
{@render chipItem(getFilterLabel(workflowFilter.pluginFilterId))}
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Actions Section -->
|
|
||||||
<div class="rounded-2xl border p-4 bg-light-50 border-light-200">
|
|
||||||
<div class="mb-3">
|
|
||||||
<Text class="text-xs font-semibold uppercase tracking-widest" color="muted">{$t('actions')}</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
{#if workflow.actions.length === 0}
|
|
||||||
<span class="text-sm text-light-600">
|
|
||||||
{$t('no_actions_added')}
|
|
||||||
</span>
|
|
||||||
{:else}
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
{#each workflow.actions as workflowAction (workflowAction.id)}
|
|
||||||
{@render chipItem(getActionLabel(workflowAction.pluginActionId))}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if expandedWorkflows.has(workflow.id)}
|
|
||||||
<VStack gap={2} class="w-full rounded-2xl border bg-light-50 p-4 border-light-200 ">
|
|
||||||
<CodeBlock code={getJson(workflow)} lineNumbers />
|
|
||||||
<Button
|
|
||||||
leadingIcon={mdiClose}
|
|
||||||
fullWidth
|
|
||||||
variant="ghost"
|
|
||||||
color="secondary"
|
|
||||||
onclick={() => toggleShowingSchema(workflow.id)}>{$t('close')}</Button
|
|
||||||
>
|
|
||||||
</VStack>
|
|
||||||
{/if}
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
</section>
|
|
||||||
</UserPageLayout>
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { authenticate } from '$lib/utils/auth';
|
|
||||||
import { getFormatter } from '$lib/utils/i18n';
|
|
||||||
import { getPlugins, getWorkflows } from '@immich/sdk';
|
|
||||||
import type { PageLoad } from './$types';
|
|
||||||
|
|
||||||
export const load = (async ({ url }) => {
|
|
||||||
await authenticate(url);
|
|
||||||
const [workflows, plugins] = await Promise.all([getWorkflows(), getPlugins()]);
|
|
||||||
const $t = await getFormatter();
|
|
||||||
|
|
||||||
return {
|
|
||||||
workflows,
|
|
||||||
plugins,
|
|
||||||
meta: {
|
|
||||||
title: $t('workflows'),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}) satisfies PageLoad;
|
|
||||||
@@ -1,619 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { beforeNavigate, goto } from '$app/navigation';
|
|
||||||
import { dragAndDrop } from '$lib/attachments/drag-and-drop.svelte';
|
|
||||||
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
|
|
||||||
import SchemaFormFields from '$lib/components/workflows/SchemaFormFields.svelte';
|
|
||||||
import WorkflowCardConnector from '$lib/components/workflows/WorkflowCardConnector.svelte';
|
|
||||||
import WorkflowJsonEditor from '$lib/components/workflows/WorkflowJsonEditor.svelte';
|
|
||||||
import WorkflowSummarySidebar from '$lib/components/workflows/WorkflowSummary.svelte';
|
|
||||||
import WorkflowTriggerCard from '$lib/components/workflows/WorkflowTriggerCard.svelte';
|
|
||||||
import { AppRoute } from '$lib/constants';
|
|
||||||
import AddWorkflowStepModal from '$lib/modals/AddWorkflowStepModal.svelte';
|
|
||||||
import {
|
|
||||||
buildWorkflowPayload,
|
|
||||||
getActionsByContext,
|
|
||||||
getFiltersByContext,
|
|
||||||
handleUpdateWorkflow,
|
|
||||||
hasWorkflowChanged,
|
|
||||||
initializeConfigs,
|
|
||||||
parseWorkflowJson,
|
|
||||||
remapConfigsOnRemove,
|
|
||||||
remapConfigsOnReorder,
|
|
||||||
type WorkflowPayload,
|
|
||||||
} from '$lib/services/workflow.service';
|
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
|
||||||
import type { PluginActionResponseDto, PluginFilterResponseDto, PluginTriggerResponseDto } from '@immich/sdk';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
CardBody,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
Container,
|
|
||||||
Field,
|
|
||||||
HStack,
|
|
||||||
Icon,
|
|
||||||
Input,
|
|
||||||
Switch,
|
|
||||||
Text,
|
|
||||||
Textarea,
|
|
||||||
VStack,
|
|
||||||
modalManager,
|
|
||||||
toastManager,
|
|
||||||
} from '@immich/ui';
|
|
||||||
import {
|
|
||||||
mdiArrowLeft,
|
|
||||||
mdiCodeJson,
|
|
||||||
mdiContentSave,
|
|
||||||
mdiFilterOutline,
|
|
||||||
mdiFlashOutline,
|
|
||||||
mdiInformationOutline,
|
|
||||||
mdiPlayCircleOutline,
|
|
||||||
mdiPlus,
|
|
||||||
mdiTrashCanOutline,
|
|
||||||
mdiViewDashboard,
|
|
||||||
} from '@mdi/js';
|
|
||||||
import { t } from 'svelte-i18n';
|
|
||||||
import type { PageData } from './$types';
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
data: PageData;
|
|
||||||
};
|
|
||||||
|
|
||||||
let { data }: Props = $props();
|
|
||||||
|
|
||||||
const triggers = data.triggers;
|
|
||||||
const filters = data.plugins.flatMap((plugin) => plugin.filters);
|
|
||||||
const actions = data.plugins.flatMap((plugin) => plugin.actions);
|
|
||||||
|
|
||||||
let previousWorkflow = data.workflow;
|
|
||||||
let editWorkflow = $state(data.workflow);
|
|
||||||
|
|
||||||
let viewMode: 'visual' | 'json' = $state('visual');
|
|
||||||
|
|
||||||
let name: string = $derived(editWorkflow.name ?? '');
|
|
||||||
let description: string = $derived(editWorkflow.description ?? '');
|
|
||||||
|
|
||||||
let selectedTrigger = $state(triggers.find((t) => t.type === editWorkflow.triggerType) ?? triggers[0]);
|
|
||||||
|
|
||||||
let triggerType = $derived(selectedTrigger.type);
|
|
||||||
|
|
||||||
let supportFilters = $derived(getFiltersByContext(filters, selectedTrigger.contextType));
|
|
||||||
let supportActions = $derived(getActionsByContext(actions, selectedTrigger.contextType));
|
|
||||||
|
|
||||||
let selectedFilters: PluginFilterResponseDto[] = $derived(
|
|
||||||
(editWorkflow.filters ?? []).flatMap((workflowFilter) =>
|
|
||||||
supportFilters.filter((supportedFilter) => supportedFilter.id === workflowFilter.pluginFilterId),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
let selectedActions: PluginActionResponseDto[] = $derived(
|
|
||||||
(editWorkflow.actions ?? []).flatMap((workflowAction) =>
|
|
||||||
supportActions.filter((supportedAction) => supportedAction.id === workflowAction.pluginActionId),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
let filterConfigs: Record<string, unknown> = $derived(initializeConfigs('filter', editWorkflow));
|
|
||||||
let actionConfigs: Record<string, unknown> = $derived(initializeConfigs('action', editWorkflow));
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
editWorkflow.triggerType = triggerType;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear filters and actions when trigger changes (context changes)
|
|
||||||
let previousContext = $state<string | undefined>(undefined);
|
|
||||||
$effect(() => {
|
|
||||||
const currentContext = selectedTrigger.contextType;
|
|
||||||
if (previousContext !== undefined && previousContext !== currentContext) {
|
|
||||||
selectedFilters = [];
|
|
||||||
selectedActions = [];
|
|
||||||
filterConfigs = {};
|
|
||||||
actionConfigs = {};
|
|
||||||
}
|
|
||||||
previousContext = currentContext;
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateWorkflow = async () => {
|
|
||||||
try {
|
|
||||||
const updated = await handleUpdateWorkflow(
|
|
||||||
editWorkflow.id,
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
editWorkflow.enabled,
|
|
||||||
triggerType,
|
|
||||||
selectedFilters,
|
|
||||||
selectedActions,
|
|
||||||
filterConfigs,
|
|
||||||
actionConfigs,
|
|
||||||
);
|
|
||||||
|
|
||||||
previousWorkflow = updated;
|
|
||||||
editWorkflow = updated;
|
|
||||||
|
|
||||||
toastManager.success($t('workflow_update_success'), {
|
|
||||||
closable: true,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error, 'Failed to update workflow');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const jsonContent = $derived(
|
|
||||||
buildWorkflowPayload(
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
editWorkflow.enabled,
|
|
||||||
triggerType,
|
|
||||||
selectedFilters,
|
|
||||||
selectedActions,
|
|
||||||
filterConfigs,
|
|
||||||
actionConfigs,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
let jsonEditorContent: WorkflowPayload = $state({
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
enabled: false,
|
|
||||||
triggerType: '',
|
|
||||||
filters: [],
|
|
||||||
actions: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
const syncFromJson = () => {
|
|
||||||
const result = parseWorkflowJson(JSON.stringify(jsonEditorContent), triggers, filters, actions);
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.data) {
|
|
||||||
name = result.data.name;
|
|
||||||
description = result.data.description;
|
|
||||||
editWorkflow.enabled = result.data.enabled;
|
|
||||||
|
|
||||||
if (result.data.trigger) {
|
|
||||||
selectedTrigger = result.data.trigger;
|
|
||||||
}
|
|
||||||
|
|
||||||
selectedFilters = result.data.filters;
|
|
||||||
selectedActions = result.data.actions;
|
|
||||||
filterConfigs = result.data.filterConfigs;
|
|
||||||
actionConfigs = result.data.actionConfigs;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let hasChanges: boolean = $derived(
|
|
||||||
hasWorkflowChanged(
|
|
||||||
previousWorkflow,
|
|
||||||
editWorkflow.enabled,
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
triggerType,
|
|
||||||
selectedFilters,
|
|
||||||
selectedActions,
|
|
||||||
filterConfigs,
|
|
||||||
actionConfigs,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
let draggedFilterIndex: number | null = $state(null);
|
|
||||||
let draggedActionIndex: number | null = $state(null);
|
|
||||||
let dragOverFilterIndex: number | null = $state(null);
|
|
||||||
let dragOverActionIndex: number | null = $state(null);
|
|
||||||
|
|
||||||
const handleFilterDragStart = (index: number) => {
|
|
||||||
draggedFilterIndex = index;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFilterDragEnter = (index: number) => {
|
|
||||||
if (draggedFilterIndex !== null && draggedFilterIndex !== index) {
|
|
||||||
dragOverFilterIndex = index;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFilterDrop = (e: DragEvent, index: number) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (draggedFilterIndex === null || draggedFilterIndex === index) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remap configs to follow the new order
|
|
||||||
filterConfigs = remapConfigsOnReorder(filterConfigs, 'filter', draggedFilterIndex, index, selectedFilters.length);
|
|
||||||
|
|
||||||
const newFilters = [...selectedFilters];
|
|
||||||
const [draggedItem] = newFilters.splice(draggedFilterIndex, 1);
|
|
||||||
newFilters.splice(index, 0, draggedItem);
|
|
||||||
selectedFilters = newFilters;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFilterDragEnd = () => {
|
|
||||||
draggedFilterIndex = null;
|
|
||||||
dragOverFilterIndex = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleActionDragStart = (index: number) => {
|
|
||||||
draggedActionIndex = index;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleActionDragEnter = (index: number) => {
|
|
||||||
if (draggedActionIndex !== null && draggedActionIndex !== index) {
|
|
||||||
dragOverActionIndex = index;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleActionDrop = (e: DragEvent, index: number) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (draggedActionIndex === null || draggedActionIndex === index) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
actionConfigs = remapConfigsOnReorder(actionConfigs, 'action', draggedActionIndex, index, selectedActions.length);
|
|
||||||
|
|
||||||
const newActions = [...selectedActions];
|
|
||||||
const [draggedItem] = newActions.splice(draggedActionIndex, 1);
|
|
||||||
newActions.splice(index, 0, draggedItem);
|
|
||||||
selectedActions = newActions;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleActionDragEnd = () => {
|
|
||||||
draggedActionIndex = null;
|
|
||||||
dragOverActionIndex = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddStep = async (type: 'action' | 'filter') => {
|
|
||||||
const result = await modalManager.show(AddWorkflowStepModal, {
|
|
||||||
filters: supportFilters,
|
|
||||||
actions: supportActions,
|
|
||||||
type,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result) {
|
|
||||||
if (result.type === 'filter') {
|
|
||||||
selectedFilters = [...selectedFilters, result.item as PluginFilterResponseDto];
|
|
||||||
} else if (result.type === 'action') {
|
|
||||||
selectedActions = [...selectedActions, result.item as PluginActionResponseDto];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveFilter = (index: number) => {
|
|
||||||
filterConfigs = remapConfigsOnRemove(filterConfigs, 'filter', index, selectedFilters.length);
|
|
||||||
selectedFilters = selectedFilters.filter((_, i) => i !== index);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveAction = (index: number) => {
|
|
||||||
actionConfigs = remapConfigsOnRemove(actionConfigs, 'action', index, selectedActions.length);
|
|
||||||
selectedActions = selectedActions.filter((_, i) => i !== index);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTriggerChange = async (newTrigger: PluginTriggerResponseDto) => {
|
|
||||||
const confirmed = await modalManager.showDialog({
|
|
||||||
prompt: $t('change_trigger_prompt'),
|
|
||||||
title: $t('change_trigger'),
|
|
||||||
confirmColor: 'primary',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!confirmed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
selectedTrigger = newTrigger;
|
|
||||||
};
|
|
||||||
|
|
||||||
let allowNavigation = $state(false);
|
|
||||||
|
|
||||||
beforeNavigate(({ cancel, to }) => {
|
|
||||||
if (hasChanges && !allowNavigation) {
|
|
||||||
cancel();
|
|
||||||
|
|
||||||
modalManager
|
|
||||||
.showDialog({
|
|
||||||
prompt: $t('workflow_navigation_prompt'),
|
|
||||||
confirmColor: 'primary',
|
|
||||||
})
|
|
||||||
.then((isConfirmed) => {
|
|
||||||
if (isConfirmed && to) {
|
|
||||||
allowNavigation = true;
|
|
||||||
void goto(to.url);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#snippet cardOrder(index: number)}
|
|
||||||
<div class="h-8 w-8 rounded-lg flex place-items-center place-content-center shrink-0 border bg-light-50">
|
|
||||||
<Text size="small" class="font-mono font-bold">
|
|
||||||
{index + 1}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
{#snippet stepSeparator()}
|
|
||||||
<div class="relative flex justify-center py-4">
|
|
||||||
<div class="absolute inset-0 flex items-center" aria-hidden="true">
|
|
||||||
<div class="w-full border-t-2 border-dashed border-light-200"></div>
|
|
||||||
</div>
|
|
||||||
<div class="relative flex justify-center text-xs uppercase">
|
|
||||||
<span class="bg-white dark:bg-black px-2 font-semibold text-light-500">THEN</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
{#snippet emptyCreateButton(title: string, description: string, onclick: () => Promise<void>)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
{onclick}
|
|
||||||
class="w-full p-8 rounded-lg border-2 border-dashed hover:border-light-400 hover:bg-light-50 transition-all flex flex-col items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
<Icon icon={mdiPlus} size="32" />
|
|
||||||
<Text size="small" class="font-medium">{title}</Text>
|
|
||||||
<Text size="tiny">{description}</Text>
|
|
||||||
</button>
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>{data.meta.title} - Immich</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<main class="pt-24 immich-scrollbar">
|
|
||||||
<Container size="medium" class="p-4" center>
|
|
||||||
{#if viewMode === 'json'}
|
|
||||||
<WorkflowJsonEditor
|
|
||||||
jsonContent={jsonEditorContent}
|
|
||||||
onApply={syncFromJson}
|
|
||||||
onContentChange={(content) => (jsonEditorContent = content)}
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<VStack gap={0}>
|
|
||||||
<Card expandable>
|
|
||||||
<CardHeader>
|
|
||||||
<div class="flex place-items-start gap-3">
|
|
||||||
<Icon icon={mdiInformationOutline} size="20" class="mt-1" />
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<CardTitle>
|
|
||||||
{$t('workflow_info')}
|
|
||||||
</CardTitle>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardBody>
|
|
||||||
<VStack gap={4}>
|
|
||||||
<div
|
|
||||||
class="relative overflow-hidden border p-4 w-full rounded-xl"
|
|
||||||
class:bg-primary-50={editWorkflow.enabled}
|
|
||||||
>
|
|
||||||
<Field
|
|
||||||
label={editWorkflow.enabled ? $t('enabled') : $t('disabled')}
|
|
||||||
for="workflow-enabled"
|
|
||||||
color={editWorkflow.enabled ? 'primary' : 'secondary'}
|
|
||||||
>
|
|
||||||
<Switch id="workflow-enabled" bind:checked={editWorkflow.enabled} />
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Field label={$t('name')} for="workflow-name" required>
|
|
||||||
<Input id="workflow-name" placeholder={$t('workflow_name')} bind:value={name} />
|
|
||||||
</Field>
|
|
||||||
<Field label={$t('description')} for="workflow-description">
|
|
||||||
<Textarea
|
|
||||||
id="workflow-description"
|
|
||||||
grow
|
|
||||||
placeholder={$t('workflow_description')}
|
|
||||||
bind:value={description}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</VStack>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div class="my-10 h-px w-[98%] bg-light-200"></div>
|
|
||||||
|
|
||||||
<Card expandable>
|
|
||||||
<CardHeader class="bg-primary-50">
|
|
||||||
<div class="flex items-start gap-3">
|
|
||||||
<Icon icon={mdiFlashOutline} size="20" class="mt-1 text-primary" />
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<CardTitle class="text-left text-primary">{$t('trigger')}</CardTitle>
|
|
||||||
<CardDescription>{$t('trigger_description')}</CardDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardBody>
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
{#each triggers as trigger (trigger.type)}
|
|
||||||
<WorkflowTriggerCard
|
|
||||||
{trigger}
|
|
||||||
selected={selectedTrigger.type === trigger.type}
|
|
||||||
onclick={() => handleTriggerChange(trigger)}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<WorkflowCardConnector />
|
|
||||||
|
|
||||||
<Card expandable>
|
|
||||||
<CardHeader class="bg-warning-50">
|
|
||||||
<div class="flex items-start gap-3">
|
|
||||||
<Icon icon={mdiFilterOutline} size="20" class="mt-1 text-warning" />
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<CardTitle class="text-left text-warning">{$t('filter')}</CardTitle>
|
|
||||||
<CardDescription>{$t('filter_description')}</CardDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardBody>
|
|
||||||
{#if selectedFilters.length === 0}
|
|
||||||
{@render emptyCreateButton($t('add_filter'), $t('add_filter_description'), () => handleAddStep('filter'))}
|
|
||||||
{:else}
|
|
||||||
{#each selectedFilters as filter, index (index)}
|
|
||||||
{#if index > 0}
|
|
||||||
{@render stepSeparator()}
|
|
||||||
{/if}
|
|
||||||
<div
|
|
||||||
{@attach dragAndDrop({
|
|
||||||
index,
|
|
||||||
onDragStart: handleFilterDragStart,
|
|
||||||
onDragEnter: handleFilterDragEnter,
|
|
||||||
onDrop: handleFilterDrop,
|
|
||||||
onDragEnd: handleFilterDragEnd,
|
|
||||||
isDragging: draggedFilterIndex === index,
|
|
||||||
isDragOver: dragOverFilterIndex === index,
|
|
||||||
})}
|
|
||||||
class="mb-4 cursor-move rounded-2xl border-2 p-4 transition-all bg-light-50 border-dashed hover:border-light-300"
|
|
||||||
>
|
|
||||||
<div class="flex items-start gap-4">
|
|
||||||
{@render cardOrder(index)}
|
|
||||||
<div class="flex-1">
|
|
||||||
<h1 class="font-bold text-lg mb-3">{filter.title}</h1>
|
|
||||||
<SchemaFormFields
|
|
||||||
schema={filter.schema}
|
|
||||||
bind:config={filterConfigs}
|
|
||||||
configKey={`filter_${index}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<Button
|
|
||||||
size="medium"
|
|
||||||
variant="ghost"
|
|
||||||
color="danger"
|
|
||||||
onclick={() => handleRemoveFilter(index)}
|
|
||||||
leadingIcon={mdiTrashCanOutline}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
fullWidth
|
|
||||||
variant="ghost"
|
|
||||||
leadingIcon={mdiPlus}
|
|
||||||
onclick={() => handleAddStep('filter')}
|
|
||||||
>
|
|
||||||
{$t('add_filter')}
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<WorkflowCardConnector />
|
|
||||||
|
|
||||||
<Card expandable expanded>
|
|
||||||
<CardHeader class="bg-success-50">
|
|
||||||
<div class="flex items-start gap-3">
|
|
||||||
<Icon icon={mdiPlayCircleOutline} size="20" class="mt-1 text-success" />
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<CardTitle class="text-left text-success">{$t('action')}</CardTitle>
|
|
||||||
<CardDescription>{$t('action_description')}</CardDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardBody>
|
|
||||||
{#if selectedActions.length === 0}
|
|
||||||
{@render emptyCreateButton($t('add_action'), $t('add_action_description'), () => handleAddStep('action'))}
|
|
||||||
{:else}
|
|
||||||
{#each selectedActions as action, index (index)}
|
|
||||||
{#if index > 0}
|
|
||||||
{@render stepSeparator()}
|
|
||||||
{/if}
|
|
||||||
<div
|
|
||||||
{@attach dragAndDrop({
|
|
||||||
index,
|
|
||||||
onDragStart: handleActionDragStart,
|
|
||||||
onDragEnter: handleActionDragEnter,
|
|
||||||
onDrop: handleActionDrop,
|
|
||||||
onDragEnd: handleActionDragEnd,
|
|
||||||
isDragging: draggedActionIndex === index,
|
|
||||||
isDragOver: dragOverActionIndex === index,
|
|
||||||
})}
|
|
||||||
class="mb-4 cursor-move rounded-2xl border-2 p-4 transition-all bg-light-50 border-dashed hover:border-light-300"
|
|
||||||
>
|
|
||||||
<div class="flex items-start gap-4">
|
|
||||||
{@render cardOrder(index)}
|
|
||||||
<div class="flex-1">
|
|
||||||
<h1 class="font-bold text-lg mb-3">{action.title}</h1>
|
|
||||||
<SchemaFormFields
|
|
||||||
schema={action.schema}
|
|
||||||
bind:config={actionConfigs}
|
|
||||||
configKey={`action_${index}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<Button
|
|
||||||
size="medium"
|
|
||||||
variant="ghost"
|
|
||||||
color="danger"
|
|
||||||
onclick={() => handleRemoveAction(index)}
|
|
||||||
leadingIcon={mdiTrashCanOutline}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
fullWidth
|
|
||||||
variant="ghost"
|
|
||||||
leadingIcon={mdiPlus}
|
|
||||||
onclick={() => handleAddStep('action')}
|
|
||||||
>
|
|
||||||
{$t('add_action')}
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
</VStack>
|
|
||||||
{/if}
|
|
||||||
</Container>
|
|
||||||
|
|
||||||
<WorkflowSummarySidebar trigger={selectedTrigger} filters={selectedFilters} actions={selectedActions} />
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<ControlAppBar onClose={() => goto(AppRoute.WORKFLOWS)} backIcon={mdiArrowLeft} tailwindClasses="fixed! top-0! w-full">
|
|
||||||
{#snippet leading()}
|
|
||||||
<Text>{data.meta.title}</Text>
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
{#snippet trailing()}
|
|
||||||
<HStack gap={4}>
|
|
||||||
<HStack gap={1} class="border rounded-lg p-1 border-light-300">
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant={viewMode === 'visual' ? 'outline' : 'ghost'}
|
|
||||||
color={viewMode === 'visual' ? 'primary' : 'secondary'}
|
|
||||||
leadingIcon={mdiViewDashboard}
|
|
||||||
onclick={() => (viewMode = 'visual')}
|
|
||||||
>
|
|
||||||
{$t('visual')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant={viewMode === 'json' ? 'outline' : 'ghost'}
|
|
||||||
color={viewMode === 'json' ? 'primary' : 'secondary'}
|
|
||||||
leadingIcon={mdiCodeJson}
|
|
||||||
onclick={() => {
|
|
||||||
viewMode = 'json';
|
|
||||||
jsonEditorContent = jsonContent;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
JSON
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
<Button leadingIcon={mdiContentSave} size="small" color="primary" onclick={updateWorkflow} disabled={!hasChanges}>
|
|
||||||
{$t('save')}
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
{/snippet}
|
|
||||||
</ControlAppBar>
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { authenticate } from '$lib/utils/auth';
|
|
||||||
import { getFormatter } from '$lib/utils/i18n';
|
|
||||||
import { getPlugins, getPluginTriggers, getWorkflow } from '@immich/sdk';
|
|
||||||
import type { PageLoad } from './$types';
|
|
||||||
|
|
||||||
export const load = (async ({ url, params }) => {
|
|
||||||
await authenticate(url);
|
|
||||||
const [plugins, workflow, triggers] = await Promise.all([
|
|
||||||
getPlugins(),
|
|
||||||
getWorkflow({ id: params.workflowId }),
|
|
||||||
getPluginTriggers(),
|
|
||||||
]);
|
|
||||||
const $t = await getFormatter();
|
|
||||||
|
|
||||||
return {
|
|
||||||
plugins,
|
|
||||||
workflow,
|
|
||||||
triggers,
|
|
||||||
meta: {
|
|
||||||
title: $t('edit_workflow'),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}) satisfies PageLoad;
|
|
||||||
Reference in New Issue
Block a user