mirror of
https://github.com/immich-app/immich.git
synced 2026-02-05 01:09:14 +03:00
Compare commits
41 Commits
v1.24.0_34
...
v1.26.0_36
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdd9f37abd | ||
|
|
a09bba454c | ||
|
|
4be9aa091b | ||
|
|
33b810de74 | ||
|
|
44ccb1eec1 | ||
|
|
bef38c670c | ||
|
|
025d7bf192 | ||
|
|
5ad2d62039 | ||
|
|
a128833e68 | ||
|
|
87f7b0849a | ||
|
|
4596a8ee01 | ||
|
|
f9b1b12b10 | ||
|
|
68b1655e7f | ||
|
|
658b64df74 | ||
|
|
e344503834 | ||
|
|
bf2760ffef | ||
|
|
db2ed2d881 | ||
|
|
fb0fa742f5 | ||
|
|
3b55cdc0be | ||
|
|
0efcc99f3e | ||
|
|
7a85164a1e | ||
|
|
ba2cda8955 | ||
|
|
9048be4c8e | ||
|
|
83716ae1bc | ||
|
|
5cd4d2d158 | ||
|
|
13bb6d469b | ||
|
|
8e4c4c34e4 | ||
|
|
3125d04f32 | ||
|
|
c436c57cc9 | ||
|
|
7f9f825589 | ||
|
|
da9aed5c11 | ||
|
|
10ef3509dd | ||
|
|
3dc538f9e6 | ||
|
|
1e29ff322d | ||
|
|
9c30d58b10 | ||
|
|
013a0f8324 | ||
|
|
07b58f46f9 | ||
|
|
566e118a19 | ||
|
|
0e18c88534 | ||
|
|
068d06b9ee | ||
|
|
0cf7606ec9 |
134
CODE_OF_CONDUCT.md
Normal file
134
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation
|
||||
in our community a harassment-free experience for everyone, regardless
|
||||
of age, body size, visible or invisible disability, ethnicity, sex
|
||||
characteristics, gender identity and expression, level of experience,
|
||||
education, socio-economic status, nationality, personal appearance,
|
||||
race, religion, or sexual identity and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open,
|
||||
welcoming, diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for
|
||||
our community include:
|
||||
|
||||
- Demonstrating empathy and kindness toward other people
|
||||
- Being respectful of differing opinions, viewpoints, and experiences
|
||||
- Giving and gracefully accepting constructive feedback
|
||||
- Accepting responsibility and apologizing to those affected by our
|
||||
mistakes, and learning from the experience
|
||||
- Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
- The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
- Trolling, insulting or derogatory comments, and personal or
|
||||
political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in
|
||||
a professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our
|
||||
standards of acceptable behavior and will take appropriate and fair
|
||||
corrective action in response to any behavior that they deem
|
||||
inappropriate, threatening, offensive, or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit,
|
||||
or reject comments, commits, code, wiki edits, issues, and other
|
||||
contributions that are not aligned to this Code of Conduct, and will
|
||||
communicate reasons for moderation decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also
|
||||
applies when an individual is officially representing the community in
|
||||
public spaces. Examples of representing our community include using an
|
||||
official e-mail address, posting via an official social media account,
|
||||
or acting as an appointed representative at an online or offline
|
||||
event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior
|
||||
may be reported to the community leaders responsible for enforcement
|
||||
at our Discord channel. All complaints
|
||||
will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and
|
||||
security of the reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in
|
||||
determining the consequences for any action they deem in violation of
|
||||
this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior
|
||||
deemed unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders,
|
||||
providing clarity around the nature of the violation and an
|
||||
explanation of why the behavior was inappropriate. A public apology
|
||||
may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued
|
||||
behavior. No interaction with the people involved, including
|
||||
unsolicited interaction with those enforcing the Code of Conduct, for
|
||||
a specified period of time. This includes avoiding interactions in
|
||||
community spaces as well as external channels like social
|
||||
media. Violating these terms may lead to a temporary or permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards,
|
||||
including sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or
|
||||
public communication with the community for a specified period of
|
||||
time. No public or private interaction with the people involved,
|
||||
including unsolicited interaction with those enforcing the Code of
|
||||
Conduct, is allowed during this period. Violating these terms may lead
|
||||
to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of
|
||||
community standards, including sustained inappropriate behavior,
|
||||
harassment of an individual, or aggression toward or disparagement of
|
||||
classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction
|
||||
within the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor
|
||||
Covenant][homepage], version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of
|
||||
conduct enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the
|
||||
FAQ at https://www.contributor-covenant.org/faq. Translations are
|
||||
available at https://www.contributor-covenant.org/translations.
|
||||
@@ -6,6 +6,7 @@ services:
|
||||
build:
|
||||
context: ../server
|
||||
dockerfile: Dockerfile
|
||||
target: builder
|
||||
command: npm run start:dev immich
|
||||
volumes:
|
||||
- ../server:/usr/src/app
|
||||
@@ -24,6 +25,7 @@ services:
|
||||
build:
|
||||
context: ../machine-learning
|
||||
dockerfile: Dockerfile
|
||||
target: builder
|
||||
command: npm run start:dev
|
||||
volumes:
|
||||
- ../machine-learning:/usr/src/app
|
||||
@@ -41,6 +43,7 @@ services:
|
||||
build:
|
||||
context: ../server
|
||||
dockerfile: Dockerfile
|
||||
target: builder
|
||||
command: npm run start:dev microservices
|
||||
volumes:
|
||||
- ../server:/usr/src/app
|
||||
|
||||
@@ -6,6 +6,7 @@ services:
|
||||
build:
|
||||
context: ../server
|
||||
dockerfile: Dockerfile
|
||||
target: builder
|
||||
command: npm run test:e2e
|
||||
expose:
|
||||
- "3000"
|
||||
|
||||
@@ -9,6 +9,8 @@ upload:
|
||||
locale_code: de-DE
|
||||
- file: mobile/assets/i18n/fr-FR.json
|
||||
locale_code: fr-FR
|
||||
- file: mobile/assets/i18n/nl-NL.json
|
||||
locale_code: nl-NL
|
||||
download:
|
||||
files:
|
||||
- file: mobile/assets/i18n/en-US.json
|
||||
@@ -17,3 +19,5 @@ download:
|
||||
locale_code: de-DE
|
||||
- file: mobile/assets/i18n/fr-FR.json
|
||||
locale_code: fr-FR
|
||||
- file: mobile/assets/i18n/nl-NL.json
|
||||
locale_code: nl-NL
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
FROM node:16-bullseye-slim
|
||||
# Build stage
|
||||
FROM node:16-bullseye-slim as builder
|
||||
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
@@ -15,3 +16,27 @@ RUN npm rebuild @tensorflow/tfjs-node --build-from-source
|
||||
COPY . .
|
||||
|
||||
RUN npm run build
|
||||
|
||||
|
||||
# Prod stage
|
||||
FROM node:16-bullseye-slim
|
||||
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
COPY entrypoint.sh ./
|
||||
|
||||
RUN mkdir -p /usr/src/app/dist \
|
||||
&& mkdir -p /usr/src/app/node_modules \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y ffmpeg \
|
||||
&& rm -rf /var/cache/apt/lists
|
||||
|
||||
COPY --from=builder /usr/src/app/node_modules ./node_modules
|
||||
COPY --from=builder /usr/src/app/dist ./dist
|
||||
|
||||
RUN npm prune --production
|
||||
|
||||
# CMD [ "node", "dist/main" ]
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
# npm run typeorm migration:run
|
||||
npm run build && npm run start:prod
|
||||
# npm run start:prod
|
||||
node dist/main.js
|
||||
|
||||
3
mobile/android/.gitignore
vendored
3
mobile/android/.gitignore
vendored
@@ -11,3 +11,6 @@ GeneratedPluginRegistrant.java
|
||||
key.properties
|
||||
**/*.keystore
|
||||
**/*.jks
|
||||
|
||||
# Fastlane
|
||||
/fastlane/report.xml
|
||||
|
||||
@@ -138,6 +138,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
||||
immediate = true,
|
||||
requireUnmeteredNetwork = inputData.getBoolean(DATA_KEY_UNMETERED, true),
|
||||
requireCharging = inputData.getBoolean(DATA_KEY_CHARGING, false),
|
||||
initialDelayInMs = ONE_MINUTE,
|
||||
retries = inputData.getInt(DATA_KEY_RETRIES, 0) + 1)
|
||||
}
|
||||
engine?.destroy()
|
||||
@@ -169,6 +170,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
||||
immediate = true,
|
||||
requireUnmeteredNetwork = inputData.getBoolean(DATA_KEY_UNMETERED, true),
|
||||
requireCharging = inputData.getBoolean(DATA_KEY_CHARGING, false),
|
||||
initialDelayInMs = ONE_MINUTE,
|
||||
retries = inputData.getInt(DATA_KEY_RETRIES, 0) + 1)
|
||||
}
|
||||
}
|
||||
@@ -186,22 +188,27 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
||||
val args = call.arguments<ArrayList<*>>()!!
|
||||
val title = args.get(0) as String
|
||||
val content = args.get(1) as String
|
||||
showError(title, content)
|
||||
val individualTag = args.get(2) as String?
|
||||
showError(title, content, individualTag)
|
||||
}
|
||||
"clearErrorNotifications" -> clearErrorNotifications()
|
||||
else -> r.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showError(title: String, content: String) {
|
||||
private fun showError(title: String, content: String, individualTag: String?) {
|
||||
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ERROR_ID)
|
||||
.setContentTitle(title)
|
||||
.setTicker(title)
|
||||
.setContentText(content)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setAutoCancel(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.build()
|
||||
val notificationId = SystemClock.uptimeMillis() as Int
|
||||
notificationManager.notify(notificationId, notification)
|
||||
notificationManager.notify(individualTag, NOTIFICATION_ERROR_ID, notification)
|
||||
}
|
||||
|
||||
private fun clearErrorNotifications() {
|
||||
notificationManager.cancel(NOTIFICATION_ERROR_ID)
|
||||
}
|
||||
|
||||
private fun createForegroundInfo(title: String = NOTIFICATION_DEFAULT_TITLE, content: String? = null): ForegroundInfo {
|
||||
@@ -212,14 +219,14 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
return ForegroundInfo(1, notification)
|
||||
return ForegroundInfo(NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private fun createChannel() {
|
||||
val foreground = NotificationChannel(NOTIFICATION_CHANNEL_ID, NOTIFICATION_CHANNEL_ID, NotificationManager.IMPORTANCE_LOW)
|
||||
notificationManager.createNotificationChannel(foreground)
|
||||
val error = NotificationChannel(NOTIFICATION_CHANNEL_ERROR_ID, NOTIFICATION_CHANNEL_ERROR_ID, NotificationManager.IMPORTANCE_HIGH)
|
||||
val error = NotificationChannel(NOTIFICATION_CHANNEL_ERROR_ID, NOTIFICATION_CHANNEL_ERROR_ID, NotificationManager.IMPORTANCE_DEFAULT)
|
||||
notificationManager.createNotificationChannel(error)
|
||||
}
|
||||
|
||||
@@ -236,6 +243,9 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
||||
private const val NOTIFICATION_CHANNEL_ID = "immich/backgroundService"
|
||||
private const val NOTIFICATION_CHANNEL_ERROR_ID = "immich/backgroundServiceError"
|
||||
private const val NOTIFICATION_DEFAULT_TITLE = "Immich"
|
||||
private const val NOTIFICATION_ID = 1
|
||||
private const val NOTIFICATION_ERROR_ID = 2
|
||||
private const val ONE_MINUTE: Long = 60000
|
||||
|
||||
/**
|
||||
* Enqueues the `BackupWorker` to run when all constraints are met.
|
||||
@@ -262,6 +272,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
||||
keepExisting: Boolean = false,
|
||||
requireUnmeteredNetwork: Boolean = false,
|
||||
requireCharging: Boolean = false,
|
||||
initialDelayInMs: Long = 0,
|
||||
retries: Int = 0) {
|
||||
if (!isEnabled(context)) {
|
||||
return
|
||||
@@ -287,9 +298,10 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
||||
val photoCheck = OneTimeWorkRequest.Builder(BackupWorker::class.java)
|
||||
.setConstraints(constraints.build())
|
||||
.setInputData(inputData)
|
||||
.setInitialDelay(initialDelayInMs, TimeUnit.MILLISECONDS)
|
||||
.setBackoffCriteria(
|
||||
BackoffPolicy.EXPONENTIAL,
|
||||
OneTimeWorkRequest.MIN_BACKOFF_MILLIS,
|
||||
ONE_MINUTE,
|
||||
TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
val policy = if (immediate) ExistingWorkPolicy.REPLACE else (if (keepExisting) ExistingWorkPolicy.KEEP else ExistingWorkPolicy.APPEND_OR_REPLACE)
|
||||
|
||||
@@ -30,8 +30,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 34,
|
||||
"android.injected.version.name" => "1.24.0",
|
||||
"android.injected.version.code" => 36,
|
||||
"android.injected.version.name" => "1.26.0",
|
||||
}
|
||||
)
|
||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
* Feature - Customization options for asset grid
|
||||
* Added pt-BR Translation: Translation into Portuguese Brazil
|
||||
* Feature - Show notifications on background backup errors
|
||||
* Optimization - Use CachedNetworkImage and separate cache for thumbnails on library page
|
||||
@@ -5,17 +5,17 @@
|
||||
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000221">
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000224">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="55.750133">
|
||||
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="65.786484">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="35.558064">
|
||||
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="36.344276">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
@@ -122,5 +122,5 @@
|
||||
"theme_setting_image_viewer_quality_title": "Qualität des Bildbetrachters",
|
||||
"theme_setting_image_viewer_quality_subtitle": "Einstellen der Qualität des Detailbildbetrachters",
|
||||
"theme_setting_three_stage_loading_title": "Dreistufiges Laden aktivieren",
|
||||
"theme_setting_three_stage_loading_subtitle": "Das dreistufige Ladeverfahren liefert die beste Bildqualität, ist dafür aber langsamer beim Laden."
|
||||
"theme_setting_three_stage_loading_subtitle": "Das dreistufige Ladeverfahren kann die Performance beim Laden verbessern, erhöht allerdings den Datenverbrauch deutlich"
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
"backup_background_service_upload_failure_notification": "Failed to upload {}",
|
||||
"backup_background_service_in_progress_notification": "Backing up your assets…",
|
||||
"backup_background_service_current_upload_notification": "Uploading {}",
|
||||
"backup_background_service_error_title": "Backup error",
|
||||
"backup_background_service_connection_failed_message": "Failed to connect to the server. Retrying…",
|
||||
"backup_background_service_backup_failed_message": "Failed to backup assets. Retrying…",
|
||||
"backup_controller_page_albums": "Backup Albums",
|
||||
"backup_controller_page_backup": "Backup",
|
||||
"backup_controller_page_backup_selected": "Selected: ",
|
||||
@@ -135,5 +138,16 @@
|
||||
"theme_setting_image_viewer_quality_title": "Image viewer quality",
|
||||
"theme_setting_image_viewer_quality_subtitle": "Adjust the quality of the detail image viewer",
|
||||
"theme_setting_three_stage_loading_title": "Enable three-stage loading",
|
||||
"theme_setting_three_stage_loading_subtitle": "The three-stage loading delivers the best quality image in exchange for a slower loading speed"
|
||||
"theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load",
|
||||
"asset_list_settings_title": "Photo Grid",
|
||||
"asset_list_settings_subtitle": "Photo grid layout settings",
|
||||
"theme_setting_asset_list_storage_indicator_title": "Show storage indicator on asset tiles",
|
||||
"theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({})",
|
||||
"setting_notifications_title": "Notifications",
|
||||
"setting_notifications_subtitle": "Adjust your notification preferences",
|
||||
"setting_notifications_notify_failures_grace_period": "Notify background backup failures: {}",
|
||||
"setting_notifications_notify_immediately": "immediately",
|
||||
"setting_notifications_notify_minutes": "{} minutes",
|
||||
"setting_notifications_notify_hours": "{} hours",
|
||||
"setting_notifications_notify_never": "never"
|
||||
}
|
||||
|
||||
153
mobile/assets/i18n/nl-NL.json
Normal file
153
mobile/assets/i18n/nl-NL.json
Normal file
@@ -0,0 +1,153 @@
|
||||
{
|
||||
"album_info_card_backup_album_excluded": "UITGESLOTEN",
|
||||
"album_info_card_backup_album_included": "INGESLOTEN",
|
||||
"album_viewer_appbar_share_delete": "Verwijder album",
|
||||
"album_viewer_appbar_share_err_delete": "Fout bij verwijderen album",
|
||||
"album_viewer_appbar_share_err_leave": "Fout bij verlaten album",
|
||||
"album_viewer_appbar_share_err_remove": "Er gaat iets mis bij het verwijderen van items uit het album",
|
||||
"album_viewer_appbar_share_err_title": "Fout bij wijzigen album titel",
|
||||
"album_viewer_appbar_share_leave": "Verlaat album",
|
||||
"album_viewer_appbar_share_remove": "Verwijder uit album",
|
||||
"album_viewer_page_share_add_users": "Voeg gebruiker toe",
|
||||
"backup_album_selection_page_albums_device": "Albums op apparaat ({})",
|
||||
"backup_album_selection_page_albums_tap": "Tik om in te voegen, dubbel tik om uit te sluiten",
|
||||
"backup_album_selection_page_assets_scatter": "Items kunnen over verschillende albums verdeeld zijn, dus albums kunnen ingesloten of uitgesloten zijn van het backup proces.",
|
||||
"backup_album_selection_page_select_albums": "Selecteer albums",
|
||||
"backup_album_selection_page_selection_info": "Selectie info",
|
||||
"backup_album_selection_page_total_assets": "Totaal unieke items",
|
||||
"backup_all": "Alle",
|
||||
"backup_background_service_default_notification": "Controleren op nieuw items…",
|
||||
"backup_background_service_disable_battery_optimizations": "Schakel batterij optimalisatie uit voor Immich om achtergrond backup in te schakelen",
|
||||
"backup_background_service_upload_failure_notification": "Fout bij upload {}",
|
||||
"backup_background_service_in_progress_notification": "Backuppen van items…",
|
||||
"backup_background_service_current_upload_notification": "Uploaden {}",
|
||||
"backup_background_service_error_title": "Backup fout",
|
||||
"backup_background_service_connection_failed_message": "Fout bij verbinden server. Opnieuw proberen…",
|
||||
"backup_background_service_backup_failed_message": "Fout bij backuppen items. Opnieuw proberen…",
|
||||
"backup_controller_page_albums": "Backup Albums",
|
||||
"backup_controller_page_backup": "Backup",
|
||||
"backup_controller_page_backup_selected": "Geselecteerd: ",
|
||||
"backup_controller_page_backup_sub": "Foto's en video's gebackupped",
|
||||
"backup_controller_page_background_description": "Gebruik achtergrondservice om automatisch nieuwe items te uploaden naar server zonder de app te openen",
|
||||
"backup_controller_page_background_wifi": "Alleen op WiFi",
|
||||
"backup_controller_page_background_charging": "Alleen tijdens opladen",
|
||||
"backup_controller_page_background_is_on": "Automatische achtergrond backup staat aan",
|
||||
"backup_controller_page_background_is_off": "Automatische achtergrond backup staat uit",
|
||||
"backup_controller_page_background_turn_on": "Zet achtergrondservice aan",
|
||||
"backup_controller_page_background_turn_off": "Zet achtergrondservice uit",
|
||||
"backup_controller_page_background_configure_error": "Achtergrondservice configuratie mislukt",
|
||||
"backup_controller_page_cancel": "Annuleren",
|
||||
"backup_controller_page_created": "Gemaakt op: {}",
|
||||
"backup_controller_page_desc_backup": "Configureer backup om automatisch nieuwe items te uploaden naar server.",
|
||||
"backup_controller_page_excluded": "Uitgezonderd: ",
|
||||
"backup_controller_page_failed": "Mislukt ({})",
|
||||
"backup_controller_page_filename": "Bestandsnaam: {} [{}]",
|
||||
"backup_controller_page_id": "ID: {}",
|
||||
"backup_controller_page_info": "Backup informatie",
|
||||
"backup_controller_page_none_selected": "Geen geselecteerd",
|
||||
"backup_controller_page_remainder": "Rest",
|
||||
"backup_controller_page_remainder_sub": "Overgebleven foto's en video's om te backuppen uit selectie",
|
||||
"backup_controller_page_select": "Selecteer",
|
||||
"backup_controller_page_server_storage": "Server Opslag",
|
||||
"backup_controller_page_start_backup": "Start Backup",
|
||||
"backup_controller_page_status_off": "Backup staat uit",
|
||||
"backup_controller_page_status_on": "Backup staat aan",
|
||||
"backup_controller_page_storage_format": "{} van {} gebruikt",
|
||||
"backup_controller_page_to_backup": "Albums om te backuppen",
|
||||
"backup_controller_page_total": "Totaal",
|
||||
"backup_controller_page_total_sub": "Alle unieke foto's en video's uit geselecteerde albums",
|
||||
"backup_controller_page_turn_off": "Backup uitzetten",
|
||||
"backup_controller_page_turn_on": "Backup aanzetten",
|
||||
"backup_controller_page_uploading_file_info": "Bestandsgegevens uploaden",
|
||||
"backup_err_only_album": "Kan niet alleen het album verwijderen",
|
||||
"backup_info_card_assets": "items",
|
||||
"control_bottom_app_bar_delete": "Verwijderen",
|
||||
"create_shared_album_page_share": "Delen",
|
||||
"create_shared_album_page_create": "Aanmaken",
|
||||
"create_shared_album_page_share_add_assets": "VOEG FOTO'S TOE",
|
||||
"create_shared_album_page_share_select_photos": "Selecteer Foto's",
|
||||
"daily_title_text_date": "E, MMM dd",
|
||||
"daily_title_text_date_year": "E, MMM dd, yyyy",
|
||||
"date_format": "E, LLL d, y • h:mm a",
|
||||
"delete_dialog_alert": "Deze items zullen permanent verwijderd worden van Immich en je apparaat",
|
||||
"delete_dialog_cancel": "Annuleren",
|
||||
"delete_dialog_ok": "Verwijderen",
|
||||
"delete_dialog_title": "Verwijder permanent",
|
||||
"exif_bottom_sheet_description": "Voeg beschrijving toe...",
|
||||
"exif_bottom_sheet_details": "DETAILS",
|
||||
"exif_bottom_sheet_location": "LOCATIE",
|
||||
"login_form_button_text": "Login",
|
||||
"login_form_email_hint": "jouwemail@email.com",
|
||||
"login_form_endpoint_hint": "http://jouw-server-ip:port/api",
|
||||
"login_form_endpoint_url": "Server URL",
|
||||
"login_form_err_http": "Voer http:// of https:// in",
|
||||
"login_form_err_invalid_email": "Ongeldige Email",
|
||||
"login_form_err_leading_whitespace": "Spatie aan het begin",
|
||||
"login_form_err_trailing_whitespace": "Spatie aan het eind",
|
||||
"login_form_failed_login": "Fout bij inloggen, controleer server url, email en wachtwoord",
|
||||
"login_form_label_email": "Email",
|
||||
"login_form_label_password": "Wachtwoord",
|
||||
"login_form_password_hint": "wachtwoord",
|
||||
"login_form_save_login": "Ingelogd blijven",
|
||||
"monthly_title_text_date_format": "MMMM y",
|
||||
"profile_drawer_client_server_up_to_date": "Client en Server zijn up-to-date",
|
||||
"profile_drawer_sign_out": "Uitloggen",
|
||||
"profile_drawer_settings": "Instellingen",
|
||||
"search_bar_hint": "Zoek je foto's",
|
||||
"search_page_no_objects": "Geen object gegevens beschikbaar",
|
||||
"search_page_no_places": "Geen locatie gegevens beschikbaar",
|
||||
"search_page_places": "Plaatsen",
|
||||
"search_page_things": "Dingen",
|
||||
"search_result_page_new_search_hint": "Nieuw resultaat",
|
||||
"select_additional_user_for_sharing_page_suggestions": "Suggesties",
|
||||
"select_user_for_sharing_page_err_album": "Album aanmaken mislukt",
|
||||
"select_user_for_sharing_page_share_suggestions": "Suggesties",
|
||||
"share_add": "Toevoegen",
|
||||
"share_add_photos": "Foto's toevoegen",
|
||||
"share_add_title": "Titel toevoegen",
|
||||
"share_create_album": "Album aanmaken",
|
||||
"share_invite": "Uitnodigen voor album",
|
||||
"sharing_page_album": "Gedeelde albums",
|
||||
"sharing_page_description": "Maak gedeelde albums om foto's en video's te delen met mensen in je netwerk.",
|
||||
"sharing_page_empty_list": "LEGE LIJST",
|
||||
"sharing_silver_appbar_create_shared_album": "Maak gedeeld album",
|
||||
"sharing_silver_appbar_share_partner": "Delen met partner",
|
||||
"tab_controller_nav_photos": "Foto's",
|
||||
"tab_controller_nav_search": "Zoeken",
|
||||
"tab_controller_nav_sharing": "Delen",
|
||||
"tab_controller_nav_library": "Bibliotheek",
|
||||
"version_announcement_overlay_ack": "Bevestig",
|
||||
"version_announcement_overlay_release_notes": "release opmerkingen",
|
||||
"version_announcement_overlay_text_1": "Er is een nieuwe versie beschikbaar van",
|
||||
"version_announcement_overlay_text_2": "neem je tijd en bezoek de ",
|
||||
"version_announcement_overlay_text_3": " controleer of je docker-compose en .env up-to-date zijn om te voorkomen dat er misconfiguraties zijn, in het bijzonder als je gebruik maakt van WatchTower of een ander mechanisme dat je server automatisch configureert.",
|
||||
"version_announcement_overlay_title": "Nieuwe server versie beschikbaar \uD83C\uDF89",
|
||||
"album_thumbnail_card_item": "1 item",
|
||||
"album_thumbnail_card_items": "{} items",
|
||||
"album_thumbnail_card_shared": " · Gedeeld",
|
||||
"library_page_albums": "Albums",
|
||||
"library_page_new_album": "Nieuw album",
|
||||
"create_album_page_untitled": "Naamloos",
|
||||
"share_dialog_preparing": "Voorbereiden...",
|
||||
"control_bottom_app_bar_share": "Delen",
|
||||
"setting_pages_app_bar_settings": "Instellingen",
|
||||
"theme_setting_theme_title": "Thema",
|
||||
"theme_setting_theme_subtitle": "Kies de thema instelling van de app",
|
||||
"theme_setting_system_theme_switch": "Automatisch (volg systeeminstelling)",
|
||||
"theme_setting_dark_mode_switch": "Donkere modus",
|
||||
"theme_setting_image_viewer_quality_title": "Foto weergave kwaliteit",
|
||||
"theme_setting_image_viewer_quality_subtitle": "Pas de kwaliteit aan van de gedetailleerde foto weergave",
|
||||
"theme_setting_three_stage_loading_title": "Drie-laags laden inschakelen",
|
||||
"theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load",
|
||||
"asset_list_settings_title": "Foto Grid",
|
||||
"asset_list_settings_subtitle": "Foto grid layout instellingen",
|
||||
"theme_setting_asset_list_storage_indicator_title": "Laat ruimte indicator zien bij item tegels",
|
||||
"theme_setting_asset_list_tiles_per_row_title": "Aantal items per rij ({})",
|
||||
"setting_notifications_title": "Notificaties",
|
||||
"setting_notifications_subtitle": "Werk je notificatievoorkeuren bij",
|
||||
"setting_notifications_notify_failures_grace_period": "Melding achtergrond backup fouten: {}",
|
||||
"setting_notifications_notify_immediately": "meteen",
|
||||
"setting_notifications_notify_minutes": "{} minuten",
|
||||
"setting_notifications_notify_hours": "{} uur",
|
||||
"setting_notifications_notify_never": "nooit"
|
||||
}
|
||||
140
mobile/assets/i18n/pt-BR.json
Normal file
140
mobile/assets/i18n/pt-BR.json
Normal file
@@ -0,0 +1,140 @@
|
||||
{
|
||||
"album_info_card_backup_album_excluded": "EXCLUÍDO",
|
||||
"album_info_card_backup_album_included": "INCLUÍDO",
|
||||
"album_viewer_appbar_share_delete": "Excluir álbum",
|
||||
"album_viewer_appbar_share_err_delete": "Falha ao excluir álbum",
|
||||
"album_viewer_appbar_share_err_leave": "Falha ao sair do álbum",
|
||||
"album_viewer_appbar_share_err_remove": "Há problemas ao remover recursos do álbum",
|
||||
"album_viewer_appbar_share_err_title": "Falha ao alterar o título do álbum",
|
||||
"album_viewer_appbar_share_leave": "Sair do álbum",
|
||||
"album_viewer_appbar_share_remove": "Remover do álbum",
|
||||
"album_viewer_page_share_add_users": "Adicionar usuários",
|
||||
"backup_album_selection_page_albums_device": "Álbuns no dispositivo ({})",
|
||||
"backup_album_selection_page_albums_tap": "Toque para incluir, toque duas vezes para excluir",
|
||||
"backup_album_selection_page_assets_scatter": "Os recursos podem se espalhar por vários álbuns. Assim, os álbuns podem ser incluídos ou excluídos durante o processo de backup.",
|
||||
"backup_album_selection_page_select_albums": "Selecionar álbuns",
|
||||
"backup_album_selection_page_selection_info": "Informações da Seleção",
|
||||
"backup_album_selection_page_total_assets": "Total de recursos exclusivos",
|
||||
"backup_all": "Todos",
|
||||
"backup_background_service_default_notification": "Checking for new assets…",
|
||||
"backup_background_service_disable_battery_optimizations": "Por favor, desabilite a otimização da bateria para Immich para habilitar o backup em segundo plano",
|
||||
"backup_background_service_upload_failure_notification": "Falha ao carregar {}",
|
||||
"backup_background_service_in_progress_notification": "Fazendo backup de seus ativos…",
|
||||
"backup_background_service_current_upload_notification": "Enviando {}",
|
||||
"backup_controller_page_albums": "Álbuns de backup",
|
||||
"backup_controller_page_backup": "Backup",
|
||||
"backup_controller_page_backup_selected": "Selecionado: ",
|
||||
"backup_controller_page_backup_sub": "Backup de fotos e vídeos",
|
||||
"backup_controller_page_background_description": "Ative o serviço em segundo plano para fazer backup automático de novos ativos sem precisar abrir o aplicativo",
|
||||
"backup_controller_page_background_wifi": "Apenas em Wi-Fi",
|
||||
"backup_controller_page_background_charging": "Apenas durante o carregamento",
|
||||
"backup_controller_page_background_is_on": "O backup automático em segundo plano está ativado",
|
||||
"backup_controller_page_background_is_off": "O backup automático em segundo plano está desativado",
|
||||
"backup_controller_page_background_turn_on": "Ativar o serviço em segundo plano",
|
||||
"backup_controller_page_background_turn_off": "Desativar o serviço em segundo plano",
|
||||
"backup_controller_page_background_configure_error": "Falha ao configurar o serviço em segundo plano",
|
||||
"backup_controller_page_cancel": "Cancelar",
|
||||
"backup_controller_page_created": "Criado em: {}",
|
||||
"backup_controller_page_desc_backup": "Ative o backup para carregar automaticamente novos ativos no servidor.",
|
||||
"backup_controller_page_excluded": "Excluído: ",
|
||||
"backup_controller_page_failed": "Falhou ({})",
|
||||
"backup_controller_page_filename": "Nome do arquivo: {} [{}]",
|
||||
"backup_controller_page_id": "ID: {}",
|
||||
"backup_controller_page_info": "Informações de backup",
|
||||
"backup_controller_page_none_selected": "Nenhum selecionado",
|
||||
"backup_controller_page_remainder": "Restante",
|
||||
"backup_controller_page_remainder_sub": "Fotos e vídeos restantes para fazer backup da seleção",
|
||||
"backup_controller_page_select": "Selecionar",
|
||||
"backup_controller_page_server_storage": "Armazenamento do servidor",
|
||||
"backup_controller_page_start_backup": "Iniciar backup",
|
||||
"backup_controller_page_status_off": "O backup está desativado",
|
||||
"backup_controller_page_status_on": "O backup está ativado",
|
||||
"backup_controller_page_storage_format": "{} de {} usado",
|
||||
"backup_controller_page_to_backup": "Álbuns para backup",
|
||||
"backup_controller_page_total": "Total",
|
||||
"backup_controller_page_total_sub": "Todas as fotos e vídeos únicos dos álbuns selecionados",
|
||||
"backup_controller_page_turn_off": "Desativar o backup",
|
||||
"backup_controller_page_turn_on": "Ativar Backup",
|
||||
"backup_controller_page_uploading_file_info": "Carregando informações do arquivo",
|
||||
"backup_err_only_album": "Não é possível remover o único álbum",
|
||||
"backup_info_card_assets": "ativos",
|
||||
"control_bottom_app_bar_delete": "Excluir",
|
||||
"create_shared_album_page_share": "Compartilhar",
|
||||
"create_shared_album_page_create": "Criar",
|
||||
"create_shared_album_page_share_add_assets": "ADICIONAR FOTOS",
|
||||
"create_shared_album_page_share_select_photos": "Selecionar fotos",
|
||||
"daily_title_text_date": "E, MMM dd",
|
||||
"daily_title_text_date_year": "E, MMM dd, yyyy",
|
||||
"date_format": "E, LLL d, y • h:mm a",
|
||||
"delete_dialog_alert": "Esses itens serão excluídos permanentemente do Immich e do seu dispositivo",
|
||||
"delete_dialog_cancel": "Cancelar",
|
||||
"delete_dialog_ok": "Excluir",
|
||||
"delete_dialog_title": "Excluir permanentemente",
|
||||
"exif_bottom_sheet_description": "Adicionar descrição...",
|
||||
"exif_bottom_sheet_details": "DETALHES",
|
||||
"exif_bottom_sheet_location": "LOCALIZAÇÃO",
|
||||
"login_form_button_text": "Login",
|
||||
"login_form_email_hint": "youremail@email.com",
|
||||
"login_form_endpoint_hint": "http://your-server-ip:port/api",
|
||||
"login_form_endpoint_url": "Server Endpoint URL",
|
||||
"login_form_err_http": "Please specify http:// or https://",
|
||||
"login_form_err_invalid_email": "E-mail inválido",
|
||||
"login_form_err_leading_whitespace": "Leading whitespace",
|
||||
"login_form_err_trailing_whitespace": "Trailing whitespace",
|
||||
"login_form_failed_login": "Erro ao fazer login, verifique a url do servidor, e-mail e senha",
|
||||
"login_form_label_email": "Email",
|
||||
"login_form_label_password": "Password",
|
||||
"login_form_password_hint": "password",
|
||||
"login_form_save_login": "Permaneçer conectado",
|
||||
"monthly_title_text_date_format": "MMMM y",
|
||||
"profile_drawer_client_server_up_to_date": "Cliente e Servidor estão atualizados",
|
||||
"profile_drawer_sign_out": "Sair",
|
||||
"profile_drawer_settings": "Configurações",
|
||||
"search_bar_hint": "Procurar fotos",
|
||||
"search_page_no_objects": "Nenhuma informação de objeto disponível",
|
||||
"search_page_no_places": "Nenhuma informação de lugares disponível",
|
||||
"search_page_places": "Lugares",
|
||||
"search_page_things": "Coisas",
|
||||
"search_result_page_new_search_hint": "Nova pesquisa",
|
||||
"select_additional_user_for_sharing_page_suggestions": "Sugestões",
|
||||
"select_user_for_sharing_page_err_album": "Falha ao criar álbum",
|
||||
"select_user_for_sharing_page_share_suggestions": "Sugestões",
|
||||
"share_add": "Adicionar",
|
||||
"share_add_photos": "Adicionar fotos",
|
||||
"share_add_title": "Adicione um título",
|
||||
"share_create_album": "Criar álbum",
|
||||
"share_invite": "Convidar para o álbum",
|
||||
"sharing_page_album": "Álbuns compartilhados",
|
||||
"sharing_page_description": "Crie álbuns compartilhados para compartilhar fotos e vídeos com pessoas em sua rede.",
|
||||
"sharing_page_empty_list": "LISTA VAZIA",
|
||||
"sharing_silver_appbar_create_shared_album": "Criar álbum compartilhado",
|
||||
"sharing_silver_appbar_share_partner": "Compartilhe com o parceiro",
|
||||
"tab_controller_nav_photos": "Fotos",
|
||||
"tab_controller_nav_search": "Procurar",
|
||||
"tab_controller_nav_sharing": "Compartilhamento",
|
||||
"tab_controller_nav_library": "Biblioteca",
|
||||
"version_announcement_overlay_ack": "Confirmar",
|
||||
"version_announcement_overlay_release_notes": "notas de lançamento",
|
||||
"version_announcement_overlay_text_1": "Oi amigo, há um novo lançamento de",
|
||||
"version_announcement_overlay_text_2": "reserve um tempo para visitar o ",
|
||||
"version_announcement_overlay_text_3": " e verifique se a configuração do docker-compose e do .env está atualizada para evitar configurações incorretas, especialmente se você usar o WatchTower ou qualquer mecanismo que lide com a atualização automática do aplicativo do servidor.",
|
||||
"version_announcement_overlay_title": "Nova versão do servidor disponível \uD83C\uDF89",
|
||||
"album_thumbnail_card_item": "1 item",
|
||||
"album_thumbnail_card_items": "{} items",
|
||||
"album_thumbnail_card_shared": " · Compartilhado",
|
||||
"library_page_albums": "Albums",
|
||||
"library_page_new_album": "Novo album",
|
||||
"create_album_page_untitled": "Sem título",
|
||||
"share_dialog_preparing": "Preparando...",
|
||||
"control_bottom_app_bar_share": "Compartilhar",
|
||||
"setting_pages_app_bar_settings": "Configurações",
|
||||
"theme_setting_theme_title": "Tema",
|
||||
"theme_setting_theme_subtitle": "Escolha a configuração de tema do app",
|
||||
"theme_setting_system_theme_switch": "Automático (seguir a configuração do sistema)",
|
||||
"theme_setting_dark_mode_switch": "Dark mode",
|
||||
"theme_setting_image_viewer_quality_title": "Qualidade das imagens do visualizador",
|
||||
"theme_setting_image_viewer_quality_subtitle": "Ajuste a qualidade de imagens detalhadas do visualizador",
|
||||
"theme_setting_three_stage_loading_title": "Ative o carregamento em três estágios",
|
||||
"theme_setting_three_stage_loading_subtitle": "O carregamento em três estágios oferece a imagem de melhor qualidade em troca de uma velocidade de carregamento mais lenta"
|
||||
}
|
||||
|
||||
@@ -360,7 +360,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 40;
|
||||
CURRENT_PROJECT_VERSION = 51;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@@ -495,7 +495,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 40;
|
||||
CURRENT_PROJECT_VERSION = 51;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@@ -522,7 +522,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 40;
|
||||
CURRENT_PROJECT_VERSION = 51;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.21.0</string>
|
||||
<string>1.26.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>40</string>
|
||||
<string>51</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true />
|
||||
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
|
||||
@@ -92,7 +92,9 @@
|
||||
<string>it</string>
|
||||
<string>fi</string>
|
||||
<string>ja</string>
|
||||
<string>nl</string>
|
||||
<string>pl</string>
|
||||
<string>pt</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -19,7 +19,7 @@ platform :ios do
|
||||
desc "iOS Beta"
|
||||
lane :beta do
|
||||
increment_version_number(
|
||||
version_number: "1.24.0"
|
||||
version_number: "1.26.0"
|
||||
)
|
||||
increment_build_number(
|
||||
build_number: latest_testflight_build_number + 1,
|
||||
|
||||
@@ -5,32 +5,32 @@
|
||||
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000205">
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000349">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.360401">
|
||||
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.650297">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="4.012696">
|
||||
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="7.757602">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.378836">
|
||||
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.421008">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="4: build_app" time="80.023705">
|
||||
<testcase classname="fastlane.lanes" name="4: build_app" time="126.240949">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="98.18403">
|
||||
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="68.206021">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
@@ -19,3 +19,7 @@ const String githubReleaseInfoKey = "immichGithubReleaseInfoKey"; // Key 1
|
||||
|
||||
// User Setting Info
|
||||
const String userSettingInfoBox = "immichUserSettingInfoBox";
|
||||
|
||||
// Background backup Info
|
||||
const String backgroundBackupInfoBox = "immichBackgroundBackupInfoBox"; // Box
|
||||
const String backupFailedSince = "immichBackupFailedSince"; // Key 1
|
||||
@@ -11,7 +11,9 @@ const List<Locale> locales = [
|
||||
Locale('fr', 'FR'),
|
||||
Locale('it', 'IT'),
|
||||
Locale('ja', 'JP'),
|
||||
Locale('pl', 'PL')
|
||||
Locale('nl', 'NL'),
|
||||
Locale('pl', 'PL'),
|
||||
Locale('pt', 'PR')
|
||||
];
|
||||
|
||||
const String translationsPath = 'assets/i18n';
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/services/cache.service.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:transparent_image/transparent_image.dart';
|
||||
|
||||
class AlbumThumbnailCard extends StatelessWidget {
|
||||
const AlbumThumbnailCard({Key? key, required this.album}) : super(key: key);
|
||||
const AlbumThumbnailCard({
|
||||
Key? key,
|
||||
required this.album,
|
||||
required this.cacheService,
|
||||
}) : super(key: key);
|
||||
|
||||
final AlbumResponseDto album;
|
||||
final CacheService cacheService;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -29,19 +38,19 @@ class AlbumThumbnailCard extends StatelessWidget {
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: FadeInImage(
|
||||
child: CachedNetworkImage(
|
||||
cacheManager: cacheService.getCache(CacheType.albumThumbnail),
|
||||
memCacheHeight: max(400, cardSize.toInt() * 3),
|
||||
width: cardSize,
|
||||
height: cardSize,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: MemoryImage(kTransparentImage),
|
||||
image: NetworkImage(
|
||||
'${box.get(serverEndpointKey)}/asset/thumbnail/${album.albumThumbnailAssetId}?format=JPEG',
|
||||
headers: {
|
||||
"Authorization": "Bearer ${box.get(accessTokenKey)}"
|
||||
},
|
||||
),
|
||||
fadeInDuration: const Duration(milliseconds: 200),
|
||||
fadeOutDuration: const Duration(milliseconds: 200),
|
||||
imageUrl:
|
||||
getAlbumThumbnailUrl(album, type: ThumbnailFormat.JPEG),
|
||||
httpHeaders: {
|
||||
"Authorization": "Bearer ${box.get(accessTokenKey)}"
|
||||
},
|
||||
cacheKey: "${album.albumThumbnailAssetId}",
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
|
||||
@@ -14,11 +14,13 @@ import 'package:openapi/api.dart';
|
||||
class AlbumViewerThumbnail extends HookConsumerWidget {
|
||||
final AssetResponseDto asset;
|
||||
final List<AssetResponseDto> assetList;
|
||||
final bool showStorageIndicator;
|
||||
|
||||
const AlbumViewerThumbnail({
|
||||
Key? key,
|
||||
required this.asset,
|
||||
required this.assetList,
|
||||
this.showStorageIndicator = true,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
@@ -166,7 +168,7 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
|
||||
child: Stack(
|
||||
children: [
|
||||
_buildThumbnailImage(),
|
||||
_buildAssetStoreLocationIcon(),
|
||||
if (showStorageIndicator) _buildAssetStoreLocationIcon(),
|
||||
if (asset.type != AssetTypeEnum.IMAGE) _buildVideoLabel(),
|
||||
if (isMultiSelectionEnable) _buildAssetSelectionIcon(),
|
||||
],
|
||||
|
||||
@@ -13,6 +13,8 @@ import 'package:immich_mobile/modules/album/ui/album_action_outlined_button.dart
|
||||
import 'package:immich_mobile/modules/album/ui/album_viewer_appbar.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/album_viewer_editable_title.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/album_viewer_thumbnail.dart';
|
||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_sliver_persistent_app_bar_delegate.dart';
|
||||
@@ -186,12 +188,17 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
Widget _buildImageGrid(AlbumResponseDto albumInfo) {
|
||||
final appSettingService = ref.watch(appSettingsServiceProvider);
|
||||
final bool showStorageIndicator =
|
||||
appSettingService.getSetting(AppSettingsEnum.storageIndicator);
|
||||
|
||||
if (albumInfo.assets.isNotEmpty) {
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.only(top: 10.0),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount:
|
||||
appSettingService.getSetting(AppSettingsEnum.tilesPerRow),
|
||||
crossAxisSpacing: 5.0,
|
||||
mainAxisSpacing: 5,
|
||||
),
|
||||
@@ -200,6 +207,7 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
return AlbumViewerThumbnail(
|
||||
asset: albumInfo.assets[index],
|
||||
assetList: albumInfo.assets,
|
||||
showStorageIndicator: showStorageIndicator,
|
||||
);
|
||||
},
|
||||
childCount: albumInfo.assetCount,
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/album_thumbnail_card.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/services/cache.service.dart';
|
||||
|
||||
class LibraryPage extends HookConsumerWidget {
|
||||
const LibraryPage({Key? key}) : super(key: key);
|
||||
@@ -13,6 +14,7 @@ class LibraryPage extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final albums = ref.watch(albumProvider);
|
||||
final cacheService = ref.watch(cacheServiceProvider);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
@@ -102,6 +104,7 @@ class LibraryPage extends HookConsumerWidget {
|
||||
_buildCreateAlbumButton(),
|
||||
for (var album in albums)
|
||||
AlbumThumbnailCard(
|
||||
cacheService: cacheService,
|
||||
album: album,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
@@ -8,8 +9,9 @@ import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/sharing_sliver_appbar.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/services/cache.service.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:transparent_image/transparent_image.dart';
|
||||
|
||||
class SharingPage extends HookConsumerWidget {
|
||||
const SharingPage({Key? key}) : super(key: key);
|
||||
@@ -19,6 +21,7 @@ class SharingPage extends HookConsumerWidget {
|
||||
var box = Hive.box(userInfoBox);
|
||||
var thumbnailRequestUrl = '${box.get(serverEndpointKey)}/asset/thumbnail';
|
||||
final List<AlbumResponseDto> sharedAlbums = ref.watch(sharedAlbumProvider);
|
||||
final CacheService cacheService = ref.watch(cacheServiceProvider);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
@@ -32,29 +35,26 @@ class SharingPage extends HookConsumerWidget {
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(BuildContext context, int index) {
|
||||
String thumbnailUrl = sharedAlbums[index].albumThumbnailAssetId !=
|
||||
null
|
||||
? "$thumbnailRequestUrl/${sharedAlbums[index].albumThumbnailAssetId}"
|
||||
: "https://images.unsplash.com/photo-1612178537253-bccd437b730e?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxzZWFyY2h8NXx8Ymxhbmt8ZW58MHx8MHx8&auto=format&fit=crop&w=700&q=60";
|
||||
final album = sharedAlbums[index];
|
||||
|
||||
return ListTile(
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
|
||||
leading: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: FadeInImage(
|
||||
child: CachedNetworkImage(
|
||||
width: 60,
|
||||
height: 60,
|
||||
memCacheHeight: 200,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: MemoryImage(kTransparentImage),
|
||||
image: NetworkImage(
|
||||
thumbnailUrl,
|
||||
headers: {
|
||||
"Authorization": "Bearer ${box.get(accessTokenKey)}"
|
||||
},
|
||||
),
|
||||
cacheManager:
|
||||
cacheService.getCache(CacheType.sharedAlbumThumbnail),
|
||||
imageUrl: getAlbumThumbnailUrl(album),
|
||||
cacheKey: album.albumThumbnailAssetId,
|
||||
httpHeaders: {
|
||||
"Authorization": "Bearer ${box.get(accessTokenKey)}"
|
||||
},
|
||||
fadeInDuration: const Duration(milliseconds: 200),
|
||||
fadeOutDuration: const Duration(milliseconds: 200),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
import 'dart:ui' show IsolateNameServer, PluginUtilities;
|
||||
import 'package:cancellation_token_http/http.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
@@ -16,6 +17,7 @@ import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dar
|
||||
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
|
||||
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/shared/services/api.service.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
@@ -39,6 +41,7 @@ class BackgroundService {
|
||||
bool _hasLock = false;
|
||||
SendPort? _waitingIsolate;
|
||||
ReceivePort? _rp;
|
||||
bool _errorGracePeriodExceeded = true;
|
||||
|
||||
bool get isForegroundInitialized {
|
||||
return _isForegroundInitialized;
|
||||
@@ -140,8 +143,8 @@ class BackgroundService {
|
||||
}
|
||||
|
||||
/// Updates the notification shown by the background service
|
||||
Future<bool> updateNotification({
|
||||
String title = "Immich",
|
||||
Future<bool> _updateNotification({
|
||||
required String title,
|
||||
String? content,
|
||||
}) async {
|
||||
if (!Platform.isAndroid) {
|
||||
@@ -153,28 +156,44 @@ class BackgroundService {
|
||||
.invokeMethod('updateNotification', [title, content]);
|
||||
}
|
||||
} catch (error) {
|
||||
debugPrint("[updateNotification] failed to communicate with plugin");
|
||||
debugPrint("[_updateNotification] failed to communicate with plugin");
|
||||
}
|
||||
return Future.value(false);
|
||||
}
|
||||
|
||||
/// Shows a new priority notification
|
||||
Future<bool> showErrorNotification(
|
||||
String title,
|
||||
String content,
|
||||
) async {
|
||||
Future<bool> _showErrorNotification({
|
||||
required String title,
|
||||
String? content,
|
||||
String? individualTag,
|
||||
}) async {
|
||||
if (!Platform.isAndroid) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
if (_isBackgroundInitialized && _errorGracePeriodExceeded) {
|
||||
return await _backgroundChannel
|
||||
.invokeMethod('showError', [title, content, individualTag]);
|
||||
}
|
||||
} catch (error) {
|
||||
debugPrint("[_showErrorNotification] failed to communicate with plugin");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> _clearErrorNotifications() async {
|
||||
if (!Platform.isAndroid) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
if (_isBackgroundInitialized) {
|
||||
return await _backgroundChannel
|
||||
.invokeMethod('showError', [title, content]);
|
||||
return await _backgroundChannel.invokeMethod('clearErrorNotifications');
|
||||
}
|
||||
} catch (error) {
|
||||
debugPrint("[showErrorNotification] failed to communicate with plugin");
|
||||
debugPrint(
|
||||
"[_clearErrorNotifications] failed to communicate with plugin");
|
||||
}
|
||||
return Future.value(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
/// await to ensure this thread (foreground or background) has exclusive access
|
||||
@@ -278,7 +297,15 @@ class BackgroundService {
|
||||
return false;
|
||||
}
|
||||
await translationsLoaded;
|
||||
return await _onAssetsChanged();
|
||||
final bool ok = await _onAssetsChanged();
|
||||
if (ok) {
|
||||
Hive.box(backgroundBackupInfoBox).delete(backupFailedSince);
|
||||
} else if (Hive.box(backgroundBackupInfoBox).get(backupFailedSince) ==
|
||||
null) {
|
||||
Hive.box(backgroundBackupInfoBox)
|
||||
.put(backupFailedSince, DateTime.now());
|
||||
}
|
||||
return ok;
|
||||
} catch (error) {
|
||||
debugPrint(error.toString());
|
||||
return false;
|
||||
@@ -303,6 +330,8 @@ class BackgroundService {
|
||||
Hive.registerAdapter(HiveBackupAlbumsAdapter());
|
||||
await Hive.openBox(userInfoBox);
|
||||
await Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox);
|
||||
await Hive.openBox(userSettingInfoBox);
|
||||
await Hive.openBox(backgroundBackupInfoBox);
|
||||
|
||||
ApiService apiService = ApiService();
|
||||
apiService.setEndpoint(Hive.box(userInfoBox).get(serverEndpointKey));
|
||||
@@ -313,23 +342,36 @@ class BackgroundService {
|
||||
await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
|
||||
final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey);
|
||||
if (backupAlbumInfo == null) {
|
||||
_clearErrorNotifications();
|
||||
return true;
|
||||
}
|
||||
|
||||
await PhotoManager.setIgnorePermissionCheck(true);
|
||||
_errorGracePeriodExceeded = _isErrorGracePeriodExceeded();
|
||||
|
||||
if (_canceledBySystem) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final List<AssetEntity> toUpload =
|
||||
await backupService.getAssetsToBackup(backupAlbumInfo);
|
||||
List<AssetEntity> toUpload =
|
||||
await backupService.buildUploadCandidates(backupAlbumInfo);
|
||||
|
||||
try {
|
||||
toUpload = await backupService.removeAlreadyUploadedAssets(toUpload);
|
||||
} catch (e) {
|
||||
_showErrorNotification(
|
||||
title: "backup_background_service_error_title".tr(),
|
||||
content: "backup_background_service_connection_failed_message".tr(),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_canceledBySystem) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (toUpload.isEmpty) {
|
||||
_clearErrorNotifications();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -343,10 +385,16 @@ class BackgroundService {
|
||||
_onBackupError,
|
||||
);
|
||||
if (ok) {
|
||||
_clearErrorNotifications();
|
||||
await box.put(
|
||||
backupInfoKey,
|
||||
backupAlbumInfo,
|
||||
);
|
||||
} else {
|
||||
_showErrorNotification(
|
||||
title: "backup_background_service_error_title".tr(),
|
||||
content: "backup_background_service_backup_failed_message".tr(),
|
||||
);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
@@ -358,20 +406,48 @@ class BackgroundService {
|
||||
void _onProgress(int sent, int total) {}
|
||||
|
||||
void _onBackupError(ErrorUploadAsset errorAssetInfo) {
|
||||
showErrorNotification(
|
||||
"backup_background_service_upload_failure_notification"
|
||||
_showErrorNotification(
|
||||
title: "Upload failed",
|
||||
content: "backup_background_service_upload_failure_notification"
|
||||
.tr(args: [errorAssetInfo.fileName]),
|
||||
errorAssetInfo.errorMessage,
|
||||
individualTag: errorAssetInfo.id,
|
||||
);
|
||||
}
|
||||
|
||||
void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset) {
|
||||
updateNotification(
|
||||
_updateNotification(
|
||||
title: "backup_background_service_in_progress_notification".tr(),
|
||||
content: "backup_background_service_current_upload_notification"
|
||||
.tr(args: [currentUploadAsset.fileName]),
|
||||
);
|
||||
}
|
||||
|
||||
bool _isErrorGracePeriodExceeded() {
|
||||
final int value = AppSettingsService()
|
||||
.getSetting(AppSettingsEnum.uploadErrorNotificationGracePeriod);
|
||||
if (value == 0) {
|
||||
return true;
|
||||
} else if (value == 5) {
|
||||
return false;
|
||||
}
|
||||
final DateTime? failedSince =
|
||||
Hive.box(backgroundBackupInfoBox).get(backupFailedSince);
|
||||
if (failedSince == null) {
|
||||
return false;
|
||||
}
|
||||
final Duration duration = DateTime.now().difference(failedSince);
|
||||
if (value == 1) {
|
||||
return duration > const Duration(minutes: 30);
|
||||
} else if (value == 2) {
|
||||
return duration > const Duration(hours: 2);
|
||||
} else if (value == 3) {
|
||||
return duration > const Duration(hours: 8);
|
||||
} else if (value == 4) {
|
||||
return duration > const Duration(hours: 24);
|
||||
}
|
||||
assert(false, "Invalid value");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// entry point called by Kotlin/Java code; needs to be a top-level function
|
||||
|
||||
@@ -41,21 +41,8 @@ class BackupService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns all assets to backup from the backup info taking into account the
|
||||
/// time of the last successfull backup per album
|
||||
Future<List<AssetEntity>> getAssetsToBackup(
|
||||
HiveBackupAlbums backupAlbumInfo,
|
||||
) async {
|
||||
final List<AssetEntity> candidates =
|
||||
await _buildUploadCandidates(backupAlbumInfo);
|
||||
|
||||
final List<AssetEntity> toUpload = candidates.isEmpty
|
||||
? []
|
||||
: await _removeAlreadyUploadedAssets(candidates);
|
||||
return toUpload;
|
||||
}
|
||||
|
||||
Future<List<AssetEntity>> _buildUploadCandidates(
|
||||
/// Returns all assets newer than the last successful backup per album
|
||||
Future<List<AssetEntity>> buildUploadCandidates(
|
||||
HiveBackupAlbums backupAlbums,
|
||||
) async {
|
||||
final filter = FilterOptionGroup(
|
||||
@@ -147,7 +134,8 @@ class BackupService {
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<List<AssetEntity>> _removeAlreadyUploadedAssets(
|
||||
/// Returns a new list of assets not yet uploaded
|
||||
Future<List<AssetEntity>> removeAlreadyUploadedAssets(
|
||||
List<AssetEntity> candidates,
|
||||
) async {
|
||||
final String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
|
||||
|
||||
@@ -7,11 +7,15 @@ import 'package:openapi/api.dart';
|
||||
class ImageGrid extends ConsumerWidget {
|
||||
final List<AssetResponseDto> assetGroup;
|
||||
final List<AssetResponseDto> sortedAssetGroup;
|
||||
final int tilesPerRow;
|
||||
final bool showStorageIndicator;
|
||||
|
||||
ImageGrid({
|
||||
Key? key,
|
||||
required this.assetGroup,
|
||||
required this.sortedAssetGroup,
|
||||
this.tilesPerRow = 4,
|
||||
this.showStorageIndicator = true,
|
||||
}) : super(key: key);
|
||||
|
||||
List<AssetResponseDto> imageSortedList = [];
|
||||
@@ -19,8 +23,8 @@ class ImageGrid extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return SliverGrid(
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 4,
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: tilesPerRow,
|
||||
crossAxisSpacing: 5.0,
|
||||
mainAxisSpacing: 5,
|
||||
),
|
||||
@@ -34,6 +38,7 @@ class ImageGrid extends ConsumerWidget {
|
||||
ThumbnailImage(
|
||||
asset: assetGroup[index],
|
||||
assetList: sortedAssetGroup,
|
||||
showStorageIndicator: showStorageIndicator,
|
||||
),
|
||||
if (assetType != AssetTypeEnum.IMAGE)
|
||||
Positioned(
|
||||
|
||||
@@ -15,8 +15,13 @@ import 'package:openapi/api.dart';
|
||||
class ThumbnailImage extends HookConsumerWidget {
|
||||
final AssetResponseDto asset;
|
||||
final List<AssetResponseDto> assetList;
|
||||
final bool showStorageIndicator;
|
||||
|
||||
const ThumbnailImage({Key? key, required this.asset, required this.assetList})
|
||||
const ThumbnailImage(
|
||||
{Key? key,
|
||||
required this.asset,
|
||||
required this.assetList,
|
||||
this.showStorageIndicator = true})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
@@ -123,7 +128,7 @@ class ThumbnailImage extends HookConsumerWidget {
|
||||
child: _buildSelectionIcon(asset),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
if (showStorageIndicator) Positioned(
|
||||
right: 10,
|
||||
bottom: 5,
|
||||
child: Icon(
|
||||
|
||||
@@ -10,6 +10,8 @@ import 'package:immich_mobile/modules/home/ui/image_grid.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/immich_sliver_appbar.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/monthly_title_text.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/profile_drawer/profile_drawer.dart';
|
||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||
@@ -21,6 +23,8 @@ class HomePage extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final appSettingService = ref.watch(appSettingsServiceProvider);
|
||||
|
||||
ScrollController scrollController = useScrollController();
|
||||
var assetGroupByDateTime = ref.watch(assetGroupByDateTimeProvider);
|
||||
List<Widget> imageGridGroup = [];
|
||||
@@ -61,35 +65,45 @@ class HomePage extends HookConsumerWidget {
|
||||
int? lastMonth;
|
||||
|
||||
assetGroupByDateTime.forEach((dateGroup, immichAssetList) {
|
||||
DateTime parseDateGroup = DateTime.parse(dateGroup);
|
||||
int currentMonth = parseDateGroup.month;
|
||||
try {
|
||||
DateTime parseDateGroup = DateTime.parse(dateGroup);
|
||||
int currentMonth = parseDateGroup.month;
|
||||
|
||||
if (lastMonth != null) {
|
||||
if (currentMonth - lastMonth! != 0) {
|
||||
imageGridGroup.add(
|
||||
MonthlyTitleText(
|
||||
isoDate: dateGroup,
|
||||
),
|
||||
);
|
||||
if (lastMonth != null) {
|
||||
if (currentMonth - lastMonth! != 0) {
|
||||
imageGridGroup.add(
|
||||
MonthlyTitleText(
|
||||
isoDate: dateGroup,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
imageGridGroup.add(
|
||||
DailyTitleText(
|
||||
key: Key('${dateGroup.toString()}title'),
|
||||
isoDate: dateGroup,
|
||||
assetGroup: immichAssetList,
|
||||
),
|
||||
);
|
||||
|
||||
imageGridGroup.add(
|
||||
ImageGrid(
|
||||
assetGroup: immichAssetList,
|
||||
sortedAssetGroup: sortedAssetList,
|
||||
tilesPerRow:
|
||||
appSettingService.getSetting(AppSettingsEnum.tilesPerRow),
|
||||
showStorageIndicator: appSettingService
|
||||
.getSetting(AppSettingsEnum.storageIndicator),
|
||||
),
|
||||
);
|
||||
|
||||
lastMonth = currentMonth;
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
"[ERROR] Cannot parse $dateGroup - Wrong create date format : ${immichAssetList.map((asset) => asset.createdAt).toList()}",
|
||||
);
|
||||
}
|
||||
|
||||
imageGridGroup.add(
|
||||
DailyTitleText(
|
||||
key: Key('${dateGroup.toString()}title'),
|
||||
isoDate: dateGroup,
|
||||
assetGroup: immichAssetList,
|
||||
),
|
||||
);
|
||||
|
||||
imageGridGroup.add(
|
||||
ImageGrid(
|
||||
assetGroup: immichAssetList,
|
||||
sortedAssetGroup: sortedAssetList,
|
||||
),
|
||||
);
|
||||
|
||||
lastMonth = currentMonth;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
|
||||
enum AppSettingsEnum {
|
||||
threeStageLoading, // true, false,
|
||||
themeMode, // "light","dark","system"
|
||||
enum AppSettingsEnum<T> {
|
||||
threeStageLoading<bool>("threeStageLoading", false),
|
||||
themeMode<String>("themeMode", "system"), // "light","dark","system"
|
||||
tilesPerRow<int>("tilesPerRow", 4),
|
||||
uploadErrorNotificationGracePeriod<int>(
|
||||
"uploadErrorNotificationGracePeriod", 2),
|
||||
storageIndicator<bool>("storageIndicator", true);
|
||||
|
||||
const AppSettingsEnum(this.hiveKey, this.defaultValue);
|
||||
|
||||
final String hiveKey;
|
||||
final T defaultValue;
|
||||
}
|
||||
|
||||
class AppSettingsService {
|
||||
@@ -15,63 +22,26 @@ class AppSettingsService {
|
||||
hiveBox = Hive.box(userSettingInfoBox);
|
||||
}
|
||||
|
||||
T getSetting<T>(AppSettingsEnum settingType) {
|
||||
var settingKey = _settingHiveBoxKeyLookup(settingType);
|
||||
|
||||
if (!hiveBox.containsKey(settingKey)) {
|
||||
T defaultSetting = _setDefaultSetting(settingType);
|
||||
return defaultSetting;
|
||||
T getSetting<T>(AppSettingsEnum<T> settingType) {
|
||||
if (!hiveBox.containsKey(settingType.hiveKey)) {
|
||||
return _setDefault(settingType);
|
||||
}
|
||||
|
||||
var result = hiveBox.get(settingKey);
|
||||
var result = hiveBox.get(settingType.hiveKey);
|
||||
|
||||
if (result is T) {
|
||||
return result;
|
||||
} else {
|
||||
debugPrint("Incorrect setting type");
|
||||
throw TypeError();
|
||||
if (result is! T) {
|
||||
return _setDefault(settingType);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
setSetting<T>(AppSettingsEnum settingType, T value) {
|
||||
var settingKey = _settingHiveBoxKeyLookup(settingType);
|
||||
|
||||
if (hiveBox.containsKey(settingKey)) {
|
||||
var result = hiveBox.get(settingKey);
|
||||
|
||||
if (result is! T) {
|
||||
debugPrint("Incorrect setting type");
|
||||
throw TypeError();
|
||||
}
|
||||
|
||||
hiveBox.put(settingKey, value);
|
||||
} else {
|
||||
hiveBox.put(settingKey, value);
|
||||
}
|
||||
setSetting<T>(AppSettingsEnum<T> settingType, T value) {
|
||||
hiveBox.put(settingType.hiveKey, value);
|
||||
}
|
||||
|
||||
_setDefaultSetting(AppSettingsEnum settingType) {
|
||||
var settingKey = _settingHiveBoxKeyLookup(settingType);
|
||||
|
||||
// Default value of threeStageLoading is false
|
||||
if (settingType == AppSettingsEnum.threeStageLoading) {
|
||||
hiveBox.put(settingKey, false);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Default value of themeMode is "light"
|
||||
if (settingType == AppSettingsEnum.themeMode) {
|
||||
hiveBox.put(settingKey, "system");
|
||||
return "system";
|
||||
}
|
||||
}
|
||||
|
||||
String _settingHiveBoxKeyLookup(AppSettingsEnum settingType) {
|
||||
switch (settingType) {
|
||||
case AppSettingsEnum.threeStageLoading:
|
||||
return 'threeStageLoading';
|
||||
case AppSettingsEnum.themeMode:
|
||||
return 'themeMode';
|
||||
}
|
||||
T _setDefault<T>(AppSettingsEnum<T> settingType) {
|
||||
hiveBox.put(settingType.hiveKey, settingType.defaultValue);
|
||||
return settingType.defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/modules/settings/ui/asset_list_settings/asset_list_storage_indicator.dart';
|
||||
import 'asset_list_tiles_per_row.dart';
|
||||
|
||||
class AssetListSettings extends StatelessWidget {
|
||||
const AssetListSettings({
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ExpansionTile(
|
||||
textColor: Theme.of(context).primaryColor,
|
||||
title: const Text(
|
||||
'asset_list_settings_title',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
).tr(),
|
||||
subtitle: const Text(
|
||||
'asset_list_settings_subtitle',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
),
|
||||
).tr(),
|
||||
children: const [
|
||||
TilesPerRow(),
|
||||
StorageIndicator(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
|
||||
class StorageIndicator extends HookConsumerWidget {
|
||||
const StorageIndicator({
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final appSettingService = ref.watch(appSettingsServiceProvider);
|
||||
|
||||
final showStorageIndicator = useState(true);
|
||||
|
||||
void switchChanged(bool value) {
|
||||
appSettingService.setSetting(AppSettingsEnum.storageIndicator, value);
|
||||
showStorageIndicator.value = value;
|
||||
|
||||
ref.invalidate(assetGroupByDateTimeProvider);
|
||||
}
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
showStorageIndicator.value = appSettingService.getSetting<bool>(AppSettingsEnum.storageIndicator);
|
||||
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return SwitchListTile.adaptive(
|
||||
activeColor: Theme.of(context).primaryColor,
|
||||
title: const Text(
|
||||
"theme_setting_asset_list_storage_indicator_title",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
),
|
||||
).tr(),
|
||||
onChanged: switchChanged,
|
||||
value: showStorageIndicator.value,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
|
||||
class TilesPerRow extends HookConsumerWidget {
|
||||
const TilesPerRow({
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final appSettingService = ref.watch(appSettingsServiceProvider);
|
||||
|
||||
final itemsValue = useState(4.0);
|
||||
|
||||
void sliderChanged(double value) {
|
||||
appSettingService.setSetting(AppSettingsEnum.tilesPerRow, value.toInt());
|
||||
itemsValue.value = value;
|
||||
}
|
||||
|
||||
void sliderChangedEnd(double _) {
|
||||
ref.invalidate(assetGroupByDateTimeProvider);
|
||||
}
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
int tilesPerRow =
|
||||
appSettingService.getSetting(AppSettingsEnum.tilesPerRow);
|
||||
itemsValue.value = tilesPerRow.toDouble();
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ListTile(
|
||||
title: const Text(
|
||||
"theme_setting_asset_list_tiles_per_row_title",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
).tr(args: ["${itemsValue.value.toInt()}"]),
|
||||
),
|
||||
Slider(
|
||||
onChangeEnd: sliderChangedEnd,
|
||||
onChanged: sliderChanged,
|
||||
value: itemsValue.value,
|
||||
min: 2,
|
||||
max: 6,
|
||||
divisions: 4,
|
||||
label: "${itemsValue.value.toInt()}",
|
||||
activeColor: Theme.of(context).primaryColor,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ class ThreeStageLoading extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
return SwitchListTile.adaptive(
|
||||
activeColor: Theme.of(context).primaryColor,
|
||||
title: const Text(
|
||||
"theme_setting_three_stage_loading_title",
|
||||
style: TextStyle(
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
|
||||
class NotificationSetting extends HookConsumerWidget {
|
||||
const NotificationSetting({
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final appSettingService = ref.watch(appSettingsServiceProvider);
|
||||
|
||||
final sliderValue = useState(0.0);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
sliderValue.value = appSettingService
|
||||
.getSetting<int>(AppSettingsEnum.uploadErrorNotificationGracePeriod)
|
||||
.toDouble();
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
final String formattedValue = _formatSliderValue(sliderValue.value);
|
||||
return ExpansionTile(
|
||||
textColor: Theme.of(context).primaryColor,
|
||||
title: const Text(
|
||||
'setting_notifications_title',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
).tr(),
|
||||
subtitle: const Text(
|
||||
'setting_notifications_subtitle',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
),
|
||||
).tr(),
|
||||
children: [
|
||||
ListTile(
|
||||
isThreeLine: false,
|
||||
dense: true,
|
||||
title: const Text(
|
||||
'setting_notifications_notify_failures_grace_period',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
).tr(args: [formattedValue]),
|
||||
subtitle: Slider(
|
||||
value: sliderValue.value,
|
||||
onChanged: (double v) => sliderValue.value = v,
|
||||
onChangeEnd: (double v) => appSettingService.setSetting(
|
||||
AppSettingsEnum.uploadErrorNotificationGracePeriod, v.toInt()),
|
||||
max: 5.0,
|
||||
divisions: 5,
|
||||
label: formattedValue,
|
||||
activeColor: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _formatSliderValue(double v) {
|
||||
if (v == 0.0) {
|
||||
return 'setting_notifications_notify_immediately'.tr();
|
||||
} else if (v == 1.0) {
|
||||
return 'setting_notifications_notify_minutes'.tr(args: const ['30']);
|
||||
} else if (v == 2.0) {
|
||||
return 'setting_notifications_notify_hours'.tr(args: const ['2']);
|
||||
} else if (v == 3.0) {
|
||||
return 'setting_notifications_notify_hours'.tr(args: const ['8']);
|
||||
} else if (v == 4.0) {
|
||||
return 'setting_notifications_notify_hours'.tr(args: const ['24']);
|
||||
} else {
|
||||
return 'setting_notifications_notify_never'.tr();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/settings/ui/asset_list_settings/asset_list_settings.dart';
|
||||
import 'package:immich_mobile/modules/settings/ui/image_viewer_quality_setting/image_viewer_quality_setting.dart';
|
||||
import 'package:immich_mobile/modules/settings/ui/notification_setting/notification_setting.dart';
|
||||
import 'package:immich_mobile/modules/settings/ui/theme_setting/theme_setting.dart';
|
||||
|
||||
class SettingsPage extends HookConsumerWidget {
|
||||
@@ -36,6 +40,8 @@ class SettingsPage extends HookConsumerWidget {
|
||||
tiles: [
|
||||
const ImageViewerQualitySetting(),
|
||||
const ThemeSetting(),
|
||||
const AssetListSettings(),
|
||||
if (Platform.isAndroid) const NotificationSetting(),
|
||||
],
|
||||
).toList(),
|
||||
],
|
||||
|
||||
21
mobile/lib/shared/services/cache.service.dart
Normal file
21
mobile/lib/shared/services/cache.service.dart
Normal file
@@ -0,0 +1,21 @@
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
enum CacheType {
|
||||
albumThumbnail,
|
||||
sharedAlbumThumbnail;
|
||||
}
|
||||
|
||||
final cacheServiceProvider = Provider((_) => CacheService());
|
||||
|
||||
class CacheService {
|
||||
|
||||
BaseCacheManager getCache(CacheType type) {
|
||||
return _getDefaultCache(type.name);
|
||||
}
|
||||
|
||||
BaseCacheManager _getDefaultCache(String cacheName) {
|
||||
return CacheManager(Config(cacheName));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -3,14 +3,31 @@ import 'package:openapi/api.dart';
|
||||
|
||||
import '../constants/hive_box.dart';
|
||||
|
||||
String getThumbnailUrl(final AssetResponseDto asset,
|
||||
{ThumbnailFormat type = ThumbnailFormat.WEBP}) {
|
||||
final box = Hive.box(userInfoBox);
|
||||
String getThumbnailUrl(
|
||||
final AssetResponseDto asset, {
|
||||
ThumbnailFormat type = ThumbnailFormat.WEBP,
|
||||
}) {
|
||||
return _getThumbnailUrl(asset.id, type: type);
|
||||
}
|
||||
|
||||
return '${box.get(serverEndpointKey)}/asset/thumbnail/${asset.id}?format=${type.value}';
|
||||
String getAlbumThumbnailUrl(
|
||||
final AlbumResponseDto album, {
|
||||
ThumbnailFormat type = ThumbnailFormat.WEBP,
|
||||
}) {
|
||||
if (album.albumThumbnailAssetId == null) {
|
||||
return '';
|
||||
}
|
||||
return _getThumbnailUrl(album.albumThumbnailAssetId!, type: type);
|
||||
}
|
||||
|
||||
String getImageUrl(final AssetResponseDto asset) {
|
||||
final box = Hive.box(userInfoBox);
|
||||
return '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=false';
|
||||
}
|
||||
|
||||
String _getThumbnailUrl(final String id,
|
||||
{ThumbnailFormat type = ThumbnailFormat.WEBP}) {
|
||||
final box = Hive.box(userInfoBox);
|
||||
|
||||
return '${box.get(serverEndpointKey)}/asset/thumbnail/${id}?format=${type.value}';
|
||||
}
|
||||
|
||||
@@ -322,7 +322,7 @@ packages:
|
||||
source: hosted
|
||||
version: "0.6.8"
|
||||
flutter_cache_manager:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_cache_manager
|
||||
url: "https://pub.dartlang.org"
|
||||
|
||||
@@ -2,7 +2,7 @@ name: immich_mobile
|
||||
description: Immich - selfhosted backup media file on mobile phone
|
||||
|
||||
publish_to: "none"
|
||||
version: 1.24.0+34
|
||||
version: 1.26.0+35
|
||||
|
||||
environment:
|
||||
sdk: ">=2.17.0 <3.0.0"
|
||||
@@ -43,6 +43,7 @@ dependencies:
|
||||
easy_localization: ^3.0.1
|
||||
share_plus: ^4.0.10
|
||||
flutter_displaymode: ^0.4.0
|
||||
flutter_cache_manager: 3.3.0
|
||||
|
||||
path: ^1.8.1
|
||||
path_provider: ^2.0.11
|
||||
|
||||
@@ -1,15 +1,32 @@
|
||||
FROM node:16-alpine3.14 as core
|
||||
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
# Build stage
|
||||
FROM node:16-alpine3.14 as builder
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
RUN apk add --update-cache build-base python3 libheif vips-dev vips ffmpeg
|
||||
|
||||
RUN apk add --update-cache build-base python3 libheif vips-dev ffmpeg
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm run build
|
||||
|
||||
|
||||
# Prod stage
|
||||
FROM node:16-alpine3.14
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
COPY start-server.sh start-microservices.sh ./
|
||||
|
||||
RUN mkdir -p /usr/src/app/dist \
|
||||
&& apk add --no-cache libheif vips ffmpeg
|
||||
|
||||
COPY --from=builder /usr/src/app/node_modules ./node_modules
|
||||
COPY --from=builder /usr/src/app/dist ./dist
|
||||
|
||||
RUN npm prune --production
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
@@ -202,7 +202,14 @@ export class AlbumRepository implements IAlbumRepository {
|
||||
|
||||
// TODO: No need to return boolean if using a singe delete query
|
||||
if (deleteAssetCount == removeAssetsDto.assetIds.length) {
|
||||
return this.get(album.id) as Promise<AlbumEntity>;
|
||||
const retAlbum = await this.get(album.id) as AlbumEntity;
|
||||
|
||||
if (retAlbum?.assets?.length === 0) { // is empty album
|
||||
await this.albumRepository.update(album.id, { albumThumbnailAssetId: null });
|
||||
retAlbum.albumThumbnailAssetId = null;
|
||||
}
|
||||
|
||||
return retAlbum;
|
||||
} else {
|
||||
throw new BadRequestException('Some assets were not found in the album');
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { APP_UPLOAD_LOCATION } from '@app/common/constants';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ServerInfoResponseDto } from './response-dto/server-info-response.dto';
|
||||
import diskusage from 'diskusage';
|
||||
import { APP_UPLOAD_LOCATION } from '../../constants/upload_location.constant';
|
||||
|
||||
@Injectable()
|
||||
export class ServerInfoService {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { immichAppConfig } from '@app/common/config';
|
||||
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
||||
import { UserModule } from './api-v1/user/user.module';
|
||||
import { AssetModule } from './api-v1/asset/asset.module';
|
||||
@@ -5,7 +6,6 @@ import { AuthModule } from './api-v1/auth/auth.module';
|
||||
import { ImmichJwtModule } from './modules/immich-jwt/immich-jwt.module';
|
||||
import { DeviceInfoModule } from './api-v1/device-info/device-info.module';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { immichAppConfig } from './config/app.config';
|
||||
import { BullModule } from '@nestjs/bull';
|
||||
import { ServerInfoModule } from './api-v1/server-info/server-info.module';
|
||||
import { BackgroundTaskModule } from './modules/background-task/background-task.module';
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { APP_UPLOAD_LOCATION } from '@app/common/constants';
|
||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
|
||||
import { existsSync, mkdirSync } from 'fs';
|
||||
import { diskStorage } from 'multer';
|
||||
import { extname } from 'path';
|
||||
import { Request } from 'express';
|
||||
import { APP_UPLOAD_LOCATION } from '../constants/upload_location.constant';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
export const assetUploadOption: MulterOptions = {
|
||||
fileFilter: (req: Request, file: any, cb: any) => {
|
||||
if (file.mimetype.match(/\/(jpg|jpeg|png|gif|mp4|x-msvideo|quicktime|heic|heif|dng|webp)$/)) {
|
||||
if (file.mimetype.match(/\/(jpg|jpeg|png|gif|mp4|x-msvideo|quicktime|heic|heif|dng|x-adobe-dng|webp|tiff)$/)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new HttpException(`Unsupported file type ${extname(file.originalname)}`, HttpStatus.BAD_REQUEST), false);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { APP_UPLOAD_LOCATION } from '@app/common/constants';
|
||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
|
||||
import { existsSync, mkdirSync } from 'fs';
|
||||
import { diskStorage } from 'multer';
|
||||
import { extname } from 'path';
|
||||
import { Request } from 'express';
|
||||
import { APP_UPLOAD_LOCATION } from '../constants/upload_location.constant';
|
||||
|
||||
export const profileImageUploadOption: MulterOptions = {
|
||||
fileFilter: (req: Request, file: any, cb: any) => {
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface IServerVersion {
|
||||
|
||||
export const serverVersion: IServerVersion = {
|
||||
major: 1,
|
||||
minor: 24,
|
||||
minor: 26,
|
||||
patch: 0,
|
||||
build: 0,
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"moduleNameMapper": {
|
||||
"@app/common/(.*)": "<rootDir>../../../libs/common/src/$1",
|
||||
"@app/database/config/(.*)": "<rootDir>../../../libs/database/src/config/$1",
|
||||
"@app/database/entities/(.*)": "<rootDir>../../../libs/database/src/entities/$1"
|
||||
}
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
import { BullModule } from '@nestjs/bull';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { immichAppConfig } from '@app/common/config';
|
||||
import { DatabaseModule } from '@app/database';
|
||||
import { AssetEntity } from '@app/database/entities/asset.entity';
|
||||
import { ExifEntity } from '@app/database/entities/exif.entity';
|
||||
import { SmartInfoEntity } from '@app/database/entities/smart-info.entity';
|
||||
import { UserEntity } from '@app/database/entities/user.entity';
|
||||
import { MicroservicesService } from './microservices.service';
|
||||
import { AssetUploadedProcessor } from './processors/asset-uploaded.processor';
|
||||
import { ThumbnailGeneratorProcessor } from './processors/thumbnail.processor';
|
||||
import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor';
|
||||
import { VideoTranscodeProcessor } from './processors/video-transcode.processor';
|
||||
import { CommunicationModule } from '../../immich/src/api-v1/communication/communication.module';
|
||||
import {
|
||||
assetUploadedQueueName,
|
||||
metadataExtractionQueueName,
|
||||
thumbnailGeneratorQueueName,
|
||||
videoConversionQueueName,
|
||||
} from '@app/job/constants/queue-name.constant';
|
||||
import { BullModule } from '@nestjs/bull';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { CommunicationModule } from '../../immich/src/api-v1/communication/communication.module';
|
||||
import { MicroservicesService } from './microservices.service';
|
||||
import { AssetUploadedProcessor } from './processors/asset-uploaded.processor';
|
||||
import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor';
|
||||
import { ThumbnailGeneratorProcessor } from './processors/thumbnail.processor';
|
||||
import { VideoTranscodeProcessor } from './processors/video-transcode.processor';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot(immichAppConfig),
|
||||
DatabaseModule,
|
||||
TypeOrmModule.forFeature([UserEntity, ExifEntity, AssetEntity, SmartInfoEntity]),
|
||||
BullModule.forRootAsync({
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { InjectQueue, Process, Processor } from '@nestjs/bull';
|
||||
import { Job, Queue } from 'bull';
|
||||
import { AssetType } from '@app/database/entities/asset.entity';
|
||||
import { randomUUID } from 'crypto';
|
||||
import {
|
||||
IAssetUploadedJob,
|
||||
IMetadataExtractionJob,
|
||||
@@ -17,6 +14,9 @@ import {
|
||||
mp4ConversionProcessorName,
|
||||
videoMetadataExtractionProcessorName,
|
||||
} from '@app/job';
|
||||
import { InjectQueue, Process, Processor } from '@nestjs/bull';
|
||||
import { Job, Queue } from 'bull';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
@Processor(assetUploadedQueueName)
|
||||
export class AssetUploadedProcessor {
|
||||
@@ -64,7 +64,11 @@ export class AssetUploadedProcessor {
|
||||
|
||||
// Extract video duration if uploaded from the web & CLI
|
||||
if (asset.type == AssetType.VIDEO) {
|
||||
await this.metadataExtractionQueue.add(videoMetadataExtractionProcessorName, { asset }, { jobId: randomUUID() });
|
||||
await this.metadataExtractionQueue.add(
|
||||
videoMetadataExtractionProcessorName,
|
||||
{ asset, fileName, fileSize },
|
||||
{ jobId: randomUUID() }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,6 @@
|
||||
import { Process, Processor } from '@nestjs/bull';
|
||||
import { Job } from 'bull';
|
||||
import { AssetEntity } from '@app/database/entities/asset.entity';
|
||||
import { Repository } from 'typeorm/repository/Repository';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { ExifEntity } from '@app/database/entities/exif.entity';
|
||||
import exifr from 'exifr';
|
||||
import mapboxGeocoding, { GeocodeService } from '@mapbox/mapbox-sdk/services/geocoding';
|
||||
import { MapiResponse } from '@mapbox/mapbox-sdk/lib/classes/mapi-response';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import axios from 'axios';
|
||||
import { SmartInfoEntity } from '@app/database/entities/smart-info.entity';
|
||||
import ffmpeg from 'fluent-ffmpeg';
|
||||
import path from 'path';
|
||||
import {
|
||||
IExifExtractionProcessor,
|
||||
IVideoLengthExtractionProcessor,
|
||||
@@ -24,6 +12,18 @@ import {
|
||||
reverseGeocodingProcessorName,
|
||||
IReverseGeocodingProcessor,
|
||||
} from '@app/job';
|
||||
import { MapiResponse } from '@mapbox/mapbox-sdk/lib/classes/mapi-response';
|
||||
import mapboxGeocoding, { GeocodeService } from '@mapbox/mapbox-sdk/services/geocoding';
|
||||
import { Process, Processor } from '@nestjs/bull';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import axios from 'axios';
|
||||
import { Job } from 'bull';
|
||||
import exifr from 'exifr';
|
||||
import ffmpeg from 'fluent-ffmpeg';
|
||||
import { readFile } from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { Repository } from 'typeorm/repository/Repository';
|
||||
|
||||
@Processor(metadataExtractionQueueName)
|
||||
export class MetadataExtractionProcessor {
|
||||
@@ -60,8 +60,8 @@ export class MetadataExtractionProcessor {
|
||||
newExif.make = exifData['Make'] || null;
|
||||
newExif.model = exifData['Model'] || null;
|
||||
newExif.imageName = path.parse(fileName).name || null;
|
||||
newExif.exifImageHeight = exifData['ExifImageHeight'] || null;
|
||||
newExif.exifImageWidth = exifData['ExifImageWidth'] || null;
|
||||
newExif.exifImageHeight = exifData['ExifImageHeight'] || exifData['ImageHeight'] || null;
|
||||
newExif.exifImageWidth = exifData['ExifImageWidth'] || exifData['ImageWidth'] || null;
|
||||
newExif.fileSizeInByte = fileSize || null;
|
||||
newExif.orientation = exifData['Orientation'] || null;
|
||||
newExif.dateTimeOriginal = exifData['DateTimeOriginal'] || null;
|
||||
@@ -85,9 +85,21 @@ export class MetadataExtractionProcessor {
|
||||
|
||||
const res: [] = geoCodeInfo.body['features'];
|
||||
|
||||
const city = res.filter((geoInfo) => geoInfo['place_type'][0] == 'place')[0]['text'];
|
||||
const state = res.filter((geoInfo) => geoInfo['place_type'][0] == 'region')[0]['text'];
|
||||
const country = res.filter((geoInfo) => geoInfo['place_type'][0] == 'country')[0]['text'];
|
||||
let city = '';
|
||||
let state = '';
|
||||
let country = '';
|
||||
|
||||
if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'place')[0]) {
|
||||
city = res.filter((geoInfo) => geoInfo['place_type'][0] == 'place')[0]['text'];
|
||||
}
|
||||
|
||||
if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'region')[0]) {
|
||||
state = res.filter((geoInfo) => geoInfo['place_type'][0] == 'region')[0]['text'];
|
||||
}
|
||||
|
||||
if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'country')[0]) {
|
||||
country = res.filter((geoInfo) => geoInfo['place_type'][0] == 'country')[0]['text'];
|
||||
}
|
||||
|
||||
newExif.city = city || null;
|
||||
newExif.state = state || null;
|
||||
@@ -114,9 +126,21 @@ export class MetadataExtractionProcessor {
|
||||
|
||||
const res: [] = geoCodeInfo.body['features'];
|
||||
|
||||
const city = res.filter((geoInfo) => geoInfo['place_type'][0] == 'place')[0]['text'];
|
||||
const state = res.filter((geoInfo) => geoInfo['place_type'][0] == 'region')[0]['text'];
|
||||
const country = res.filter((geoInfo) => geoInfo['place_type'][0] == 'country')[0]['text'];
|
||||
let city = '';
|
||||
let state = '';
|
||||
let country = '';
|
||||
|
||||
if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'place')[0]) {
|
||||
city = res.filter((geoInfo) => geoInfo['place_type'][0] == 'place')[0]['text'];
|
||||
}
|
||||
|
||||
if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'region')[0]) {
|
||||
state = res.filter((geoInfo) => geoInfo['place_type'][0] == 'region')[0]['text'];
|
||||
}
|
||||
|
||||
if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'country')[0]) {
|
||||
country = res.filter((geoInfo) => geoInfo['place_type'][0] == 'country')[0]['text'];
|
||||
}
|
||||
|
||||
await this.exifRepository.update({ id: exif.id }, { city, state, country });
|
||||
}
|
||||
@@ -166,33 +190,130 @@ export class MetadataExtractionProcessor {
|
||||
|
||||
@Process({ name: videoMetadataExtractionProcessorName, concurrency: 2 })
|
||||
async extractVideoMetadata(job: Job<IVideoLengthExtractionProcessor>) {
|
||||
const { asset } = job.data;
|
||||
const { asset, fileName } = job.data;
|
||||
|
||||
ffmpeg.ffprobe(asset.originalPath, async (err, data) => {
|
||||
if (!err) {
|
||||
let durationString = asset.duration;
|
||||
let createdAt = asset.createdAt;
|
||||
try {
|
||||
const data = await new Promise<ffmpeg.FfprobeData>((resolve, reject) =>
|
||||
ffmpeg.ffprobe(asset.originalPath, (err, data) => {
|
||||
if (err) return reject(err);
|
||||
return resolve(data);
|
||||
}),
|
||||
);
|
||||
let durationString = asset.duration;
|
||||
let createdAt = asset.createdAt;
|
||||
|
||||
if (data.format.duration) {
|
||||
durationString = this.extractDuration(data.format.duration);
|
||||
}
|
||||
if (data.format.duration) {
|
||||
durationString = this.extractDuration(data.format.duration);
|
||||
}
|
||||
|
||||
const videoTags = data.format.tags;
|
||||
if (videoTags) {
|
||||
if (videoTags['com.apple.quicktime.creationdate']) {
|
||||
createdAt = String(videoTags['com.apple.quicktime.creationdate']);
|
||||
} else if (videoTags['creation_time']) {
|
||||
createdAt = String(videoTags['creation_time']);
|
||||
} else {
|
||||
createdAt = asset.createdAt;
|
||||
}
|
||||
const videoTags = data.format.tags;
|
||||
if (videoTags) {
|
||||
if (videoTags['com.apple.quicktime.creationdate']) {
|
||||
createdAt = String(videoTags['com.apple.quicktime.creationdate']);
|
||||
} else if (videoTags['creation_time']) {
|
||||
createdAt = String(videoTags['creation_time']);
|
||||
} else {
|
||||
createdAt = asset.createdAt;
|
||||
}
|
||||
|
||||
await this.assetRepository.update({ id: asset.id }, { duration: durationString, createdAt: createdAt });
|
||||
} else {
|
||||
createdAt = asset.createdAt;
|
||||
}
|
||||
});
|
||||
|
||||
const newExif = new ExifEntity();
|
||||
newExif.assetId = asset.id;
|
||||
newExif.description = '';
|
||||
newExif.imageName = path.parse(fileName).name || null;
|
||||
newExif.fileSizeInByte = data.format.size || null;
|
||||
newExif.dateTimeOriginal = createdAt ? new Date(createdAt) : null;
|
||||
newExif.modifyDate = null;
|
||||
newExif.latitude = null;
|
||||
newExif.longitude = null;
|
||||
newExif.city = null;
|
||||
newExif.state = null;
|
||||
newExif.country = null;
|
||||
newExif.fps = null;
|
||||
|
||||
if (videoTags && videoTags['location']) {
|
||||
const location = videoTags['location'] as string;
|
||||
const locationRegex = /([+-][0-9]+\.[0-9]+)([+-][0-9]+\.[0-9]+)\/$/;
|
||||
const match = location.match(locationRegex);
|
||||
|
||||
if (match?.length === 3) {
|
||||
newExif.latitude = parseFloat(match[1]);
|
||||
newExif.longitude = parseFloat(match[2]);
|
||||
}
|
||||
} else if (videoTags && videoTags['com.apple.quicktime.location.ISO6709']) {
|
||||
const location = videoTags['com.apple.quicktime.location.ISO6709'] as string;
|
||||
const locationRegex = /([+-][0-9]+\.[0-9]+)([+-][0-9]+\.[0-9]+)([+-][0-9]+\.[0-9]+)\/$/;
|
||||
const match = location.match(locationRegex);
|
||||
|
||||
if (match?.length === 4) {
|
||||
newExif.latitude = parseFloat(match[1]);
|
||||
newExif.longitude = parseFloat(match[2]);
|
||||
}
|
||||
}
|
||||
|
||||
// Reverse GeoCoding
|
||||
if (this.geocodingClient && newExif.longitude && newExif.latitude) {
|
||||
const geoCodeInfo: MapiResponse = await this.geocodingClient
|
||||
.reverseGeocode({
|
||||
query: [newExif.longitude, newExif.latitude],
|
||||
types: ['country', 'region', 'place'],
|
||||
})
|
||||
.send();
|
||||
|
||||
const res: [] = geoCodeInfo.body['features'];
|
||||
|
||||
let city = '';
|
||||
let state = '';
|
||||
let country = '';
|
||||
|
||||
if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'place')[0]) {
|
||||
city = res.filter((geoInfo) => geoInfo['place_type'][0] == 'place')[0]['text'];
|
||||
}
|
||||
|
||||
if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'region')[0]) {
|
||||
state = res.filter((geoInfo) => geoInfo['place_type'][0] == 'region')[0]['text'];
|
||||
}
|
||||
|
||||
if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'country')[0]) {
|
||||
country = res.filter((geoInfo) => geoInfo['place_type'][0] == 'country')[0]['text'];
|
||||
}
|
||||
|
||||
newExif.city = city || null;
|
||||
newExif.state = state || null;
|
||||
newExif.country = country || null;
|
||||
}
|
||||
|
||||
for (const stream of data.streams) {
|
||||
if (stream.codec_type === 'video') {
|
||||
newExif.exifImageWidth = stream.width || null;
|
||||
newExif.exifImageHeight = stream.height || null;
|
||||
|
||||
if (typeof stream.rotation === 'string') {
|
||||
newExif.orientation = stream.rotation;
|
||||
} else if (typeof stream.rotation === 'number') {
|
||||
newExif.orientation = `${stream.rotation}`;
|
||||
} else {
|
||||
newExif.orientation = null;
|
||||
}
|
||||
|
||||
if (stream.r_frame_rate) {
|
||||
let fpsParts = stream.r_frame_rate.split('/');
|
||||
|
||||
if (fpsParts.length === 2) {
|
||||
newExif.fps = Math.round(parseInt(fpsParts[0]) / parseInt(fpsParts[1]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.exifRepository.save(newExif);
|
||||
await this.assetRepository.update({ id: asset.id }, { duration: durationString, createdAt: createdAt });
|
||||
} catch (err) {
|
||||
// do nothing
|
||||
console.log('Error in video metadata extraction', err);
|
||||
}
|
||||
}
|
||||
|
||||
private extractDuration(duration: number) {
|
||||
@@ -202,8 +323,6 @@ export class MetadataExtractionProcessor {
|
||||
const minutes = Math.floor((videoDurationInSecond - hours * 3600) / 60);
|
||||
const seconds = videoDurationInSecond - hours * 3600 - minutes * 60;
|
||||
|
||||
return `${hours}:${minutes < 10 ? '0' + minutes.toString() : minutes}:${
|
||||
seconds < 10 ? '0' + seconds.toString() : seconds
|
||||
}.000000`;
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.000000`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,4 @@
|
||||
import { InjectQueue, Process, Processor } from '@nestjs/bull';
|
||||
import { Job, Queue } from 'bull';
|
||||
import { AssetEntity, AssetType } from '@app/database/entities/asset.entity';
|
||||
import { Repository } from 'typeorm/repository/Repository';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import sharp from 'sharp';
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { CommunicationGateway } from '../../../immich/src/api-v1/communication/communication.gateway';
|
||||
import ffmpeg from 'fluent-ffmpeg';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import {
|
||||
WebpGeneratorProcessor,
|
||||
generateJPEGThumbnailProcessorName,
|
||||
@@ -19,7 +9,17 @@ import {
|
||||
thumbnailGeneratorQueueName,
|
||||
JpegGeneratorProcessor,
|
||||
} from '@app/job';
|
||||
import { InjectQueue, Process, Processor } from '@nestjs/bull';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { mapAsset } from 'apps/immich/src/api-v1/asset/response-dto/asset-response.dto';
|
||||
import { Job, Queue } from 'bull';
|
||||
import ffmpeg from 'fluent-ffmpeg';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import sharp from 'sharp';
|
||||
import { Repository } from 'typeorm/repository/Repository';
|
||||
import { CommunicationGateway } from '../../../immich/src/api-v1/communication/communication.gateway';
|
||||
|
||||
@Processor(thumbnailGeneratorQueueName)
|
||||
export class ThumbnailGeneratorProcessor {
|
||||
@@ -51,62 +51,47 @@ export class ThumbnailGeneratorProcessor {
|
||||
const jpegThumbnailPath = resizePath + originalFilename + '.jpeg';
|
||||
|
||||
if (asset.type == AssetType.IMAGE) {
|
||||
sharp(asset.originalPath)
|
||||
.resize(1440, 2560, { fit: 'inside' })
|
||||
.jpeg()
|
||||
.rotate()
|
||||
.toFile(jpegThumbnailPath, async (err) => {
|
||||
if (!err) {
|
||||
await this.assetRepository.update({ id: asset.id }, { resizePath: jpegThumbnailPath });
|
||||
await sharp(asset.originalPath).resize(1440, 2560, { fit: 'inside' }).jpeg().rotate().toFile(jpegThumbnailPath);
|
||||
await this.assetRepository.update({ id: asset.id }, { resizePath: jpegThumbnailPath });
|
||||
|
||||
// Update resize path to send to generate webp queue
|
||||
asset.resizePath = jpegThumbnailPath;
|
||||
// Update resize path to send to generate webp queue
|
||||
asset.resizePath = jpegThumbnailPath;
|
||||
|
||||
await this.thumbnailGeneratorQueue.add(
|
||||
generateWEBPThumbnailProcessorName,
|
||||
{ asset },
|
||||
{ jobId: randomUUID() },
|
||||
);
|
||||
await this.metadataExtractionQueue.add(imageTaggingProcessorName, { asset }, { jobId: randomUUID() });
|
||||
await this.metadataExtractionQueue.add(objectDetectionProcessorName, { asset }, { jobId: randomUUID() });
|
||||
this.wsCommunicationGateway.server
|
||||
.to(asset.userId)
|
||||
.emit('on_upload_success', JSON.stringify(mapAsset(asset)));
|
||||
}
|
||||
});
|
||||
await this.thumbnailGeneratorQueue.add(generateWEBPThumbnailProcessorName, { asset }, { jobId: randomUUID() });
|
||||
await this.metadataExtractionQueue.add(imageTaggingProcessorName, { asset }, { jobId: randomUUID() });
|
||||
await this.metadataExtractionQueue.add(objectDetectionProcessorName, { asset }, { jobId: randomUUID() });
|
||||
this.wsCommunicationGateway.server.to(asset.userId).emit('on_upload_success', JSON.stringify(mapAsset(asset)));
|
||||
}
|
||||
|
||||
if (asset.type == AssetType.VIDEO) {
|
||||
ffmpeg(asset.originalPath)
|
||||
.outputOptions(['-ss 00:00:00.000', '-frames:v 1'])
|
||||
.output(jpegThumbnailPath)
|
||||
.on('start', () => {
|
||||
Logger.log('Start Generating Video Thumbnail', 'generateJPEGThumbnail');
|
||||
})
|
||||
.on('error', (error) => {
|
||||
Logger.error(`Cannot Generate Video Thumbnail ${error}`, 'generateJPEGThumbnail');
|
||||
// reject();
|
||||
})
|
||||
.on('end', async () => {
|
||||
Logger.log(`Generating Video Thumbnail Success ${asset.id}`, 'generateJPEGThumbnail');
|
||||
await this.assetRepository.update({ id: asset.id }, { resizePath: jpegThumbnailPath });
|
||||
await new Promise((resolve, reject) => {
|
||||
ffmpeg(asset.originalPath)
|
||||
.outputOptions(['-ss 00:00:00.000', '-frames:v 1'])
|
||||
.output(jpegThumbnailPath)
|
||||
.on('start', () => {
|
||||
Logger.log('Start Generating Video Thumbnail', 'generateJPEGThumbnail');
|
||||
})
|
||||
.on('error', (error) => {
|
||||
Logger.error(`Cannot Generate Video Thumbnail ${error}`, 'generateJPEGThumbnail');
|
||||
reject(error);
|
||||
})
|
||||
.on('end', async () => {
|
||||
Logger.log(`Generating Video Thumbnail Success ${asset.id}`, 'generateJPEGThumbnail');
|
||||
resolve(asset);
|
||||
})
|
||||
.run();
|
||||
});
|
||||
|
||||
// Update resize path to send to generate webp queue
|
||||
asset.resizePath = jpegThumbnailPath;
|
||||
await this.assetRepository.update({ id: asset.id }, { resizePath: jpegThumbnailPath });
|
||||
|
||||
await this.thumbnailGeneratorQueue.add(
|
||||
generateWEBPThumbnailProcessorName,
|
||||
{ asset },
|
||||
{ jobId: randomUUID() },
|
||||
);
|
||||
await this.metadataExtractionQueue.add(imageTaggingProcessorName, { asset }, { jobId: randomUUID() });
|
||||
await this.metadataExtractionQueue.add(objectDetectionProcessorName, { asset }, { jobId: randomUUID() });
|
||||
// Update resize path to send to generate webp queue
|
||||
asset.resizePath = jpegThumbnailPath;
|
||||
|
||||
this.wsCommunicationGateway.server
|
||||
.to(asset.userId)
|
||||
.emit('on_upload_success', JSON.stringify(mapAsset(asset)));
|
||||
})
|
||||
.run();
|
||||
await this.thumbnailGeneratorQueue.add(generateWEBPThumbnailProcessorName, { asset }, { jobId: randomUUID() });
|
||||
await this.metadataExtractionQueue.add(imageTaggingProcessorName, { asset }, { jobId: randomUUID() });
|
||||
await this.metadataExtractionQueue.add(objectDetectionProcessorName, { asset }, { jobId: randomUUID() });
|
||||
|
||||
this.wsCommunicationGateway.server.to(asset.userId).emit('on_upload_success', JSON.stringify(mapAsset(asset)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,16 +102,10 @@ export class ThumbnailGeneratorProcessor {
|
||||
if (!asset.resizePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const webpPath = asset.resizePath.replace('jpeg', 'webp');
|
||||
|
||||
sharp(asset.resizePath)
|
||||
.resize(250)
|
||||
.webp()
|
||||
.rotate()
|
||||
.toFile(webpPath, (err) => {
|
||||
if (!err) {
|
||||
this.assetRepository.update({ id: asset.id }, { webpPath: webpPath });
|
||||
}
|
||||
});
|
||||
await sharp(asset.resizePath).resize(250).webp().rotate().toFile(webpPath);
|
||||
await this.assetRepository.update({ id: asset.id }, { webpPath: webpPath });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { APP_UPLOAD_LOCATION } from '@app/common/constants';
|
||||
import { AssetEntity } from '@app/database/entities/asset.entity';
|
||||
import { mp4ConversionProcessorName } from '@app/job/constants/job-name.constant';
|
||||
import { videoConversionQueueName } from '@app/job/constants/queue-name.constant';
|
||||
import { IMp4ConversionProcessor } from '@app/job/interfaces/video-transcode.interface';
|
||||
@@ -8,8 +10,6 @@ import { Job } from 'bull';
|
||||
import ffmpeg from 'fluent-ffmpeg';
|
||||
import { existsSync, mkdirSync } from 'fs';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AssetEntity } from '../../../../libs/database/src/entities/asset.entity';
|
||||
import { APP_UPLOAD_LOCATION } from '../../../immich/src/constants/upload_location.constant';
|
||||
|
||||
@Processor(videoConversionQueueName)
|
||||
export class VideoTranscodeProcessor {
|
||||
|
||||
1
server/libs/common/src/config/index.ts
Normal file
1
server/libs/common/src/config/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './app.config';
|
||||
1
server/libs/common/src/constants/index.ts
Normal file
1
server/libs/common/src/constants/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './upload_location.constant';
|
||||
2
server/libs/common/src/index.ts
Normal file
2
server/libs/common/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './config';
|
||||
export * from './constants';
|
||||
9
server/libs/common/tsconfig.lib.json
Normal file
9
server/libs/common/tsconfig.lib.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"outDir": "../../dist/libs/common"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
|
||||
}
|
||||
@@ -13,14 +13,9 @@ export class ExifEntity {
|
||||
@Column({ type: 'uuid' })
|
||||
assetId!: string;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
make!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
model!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
imageName!: string | null;
|
||||
/* General info */
|
||||
@Column({ type: 'text', nullable: true, default: '' })
|
||||
description!: string; // or caption
|
||||
|
||||
@Column({ type: 'integer', nullable: true })
|
||||
exifImageWidth!: number | null;
|
||||
@@ -28,7 +23,7 @@ export class ExifEntity {
|
||||
@Column({ type: 'integer', nullable: true })
|
||||
exifImageHeight!: number | null;
|
||||
|
||||
@Column({ type: 'integer', nullable: true })
|
||||
@Column({ type: 'bigint', nullable: true })
|
||||
fileSizeInByte!: number | null;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
@@ -40,21 +35,6 @@ export class ExifEntity {
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
modifyDate!: Date | null;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
lensModel!: string | null;
|
||||
|
||||
@Column({ type: 'float8', nullable: true })
|
||||
fNumber!: number | null;
|
||||
|
||||
@Column({ type: 'float8', nullable: true })
|
||||
focalLength!: number | null;
|
||||
|
||||
@Column({ type: 'integer', nullable: true })
|
||||
iso!: number | null;
|
||||
|
||||
@Column({ type: 'float', nullable: true })
|
||||
exposureTime!: number | null;
|
||||
|
||||
@Column({ type: 'float', nullable: true })
|
||||
latitude!: number | null;
|
||||
|
||||
@@ -70,9 +50,38 @@ export class ExifEntity {
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
country!: string | null;
|
||||
|
||||
/* Image info */
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
make!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
model!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
imageName!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
lensModel!: string | null;
|
||||
|
||||
@Column({ type: 'float8', nullable: true })
|
||||
fNumber!: number | null;
|
||||
|
||||
@Column({ type: 'float8', nullable: true })
|
||||
focalLength!: number | null;
|
||||
|
||||
@Column({ type: 'integer', nullable: true })
|
||||
iso!: number | null;
|
||||
|
||||
@Column({ type: 'float', nullable: true })
|
||||
exposureTime!: number | null;
|
||||
|
||||
/* Video info */
|
||||
@Column({ type: 'float8', nullable: true })
|
||||
fps?: number | null;
|
||||
|
||||
@OneToOne(() => AssetEntity, { onDelete: 'CASCADE', nullable: true })
|
||||
@JoinColumn({ name: 'assetId', referencedColumnName: 'id' })
|
||||
asset?: ExifEntity;
|
||||
asset?: AssetEntity;
|
||||
|
||||
@Index('exif_text_searchable', { synchronize: false })
|
||||
@Column({
|
||||
|
||||
@@ -18,5 +18,5 @@ export class SmartInfoEntity {
|
||||
|
||||
@OneToOne(() => AssetEntity, { onDelete: 'CASCADE', nullable: true })
|
||||
@JoinColumn({ name: 'assetId', referencedColumnName: 'id' })
|
||||
asset?: SmartInfoEntity;
|
||||
asset?: AssetEntity;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddCaption1661011331242 implements MigrationInterface {
|
||||
name = 'AddCaption1661011331242';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "exif" ADD "description" text DEFAULT ''`);
|
||||
await queryRunner.query(`ALTER TABLE "exif" ADD "fps" double precision`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "fps"`);
|
||||
await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "description"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class ChangeExifFileSizeInByteToBigInt1661528919411 implements MigrationInterface {
|
||||
name = 'ChangeExifFileSizeInByteToBigInt1661528919411';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE exif
|
||||
ALTER COLUMN "fileSizeInByte" type bigint using "fileSizeInByte"::bigint;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE exif
|
||||
ALTER COLUMN "fileSizeInByte" type integer using "fileSizeInByte"::integer;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,16 @@ export interface IVideoLengthExtractionProcessor {
|
||||
* The Asset entity that was saved in the database
|
||||
*/
|
||||
asset: AssetEntity;
|
||||
|
||||
/**
|
||||
* Original file name
|
||||
*/
|
||||
fileName: string;
|
||||
|
||||
/**
|
||||
* File size in byte
|
||||
*/
|
||||
fileSize: number;
|
||||
}
|
||||
|
||||
export interface IReverseGeocodingProcessor {
|
||||
|
||||
@@ -33,6 +33,15 @@
|
||||
"tsConfigPath": "apps/microservices/tsconfig.app.json"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"type": "library",
|
||||
"root": "libs/common",
|
||||
"entryFile": "index",
|
||||
"sourceRoot": "libs/common/src",
|
||||
"compilerOptions": {
|
||||
"tsConfigPath": "libs/common/tsconfig.lib.json"
|
||||
}
|
||||
},
|
||||
"database": {
|
||||
"type": "library",
|
||||
"root": "libs/database",
|
||||
|
||||
@@ -16,6 +16,12 @@
|
||||
"esModuleInterop": true,
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@app/common": [
|
||||
"libs/common/src"
|
||||
],
|
||||
"@app/common/*": [
|
||||
"libs/common/src/*"
|
||||
],
|
||||
"@app/database": [
|
||||
"libs/database/src"
|
||||
],
|
||||
|
||||
1550
web/package-lock.json
generated
1550
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,6 @@
|
||||
"build": "vite build",
|
||||
"package": "svelte-kit package",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --check --plugin-search-dir=. . && eslint .",
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './open-api';
|
||||
export * from './api';
|
||||
export * from './utils';
|
||||
|
||||
12
web/src/api/utils.ts
Normal file
12
web/src/api/utils.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
let _basePath = '/api';
|
||||
|
||||
export function getFileUrl(aid: string, did: string, isThumb?: boolean, isWeb?: boolean) {
|
||||
const urlObj = new URL(`${window.location.origin}${_basePath}/asset/file`);
|
||||
|
||||
urlObj.searchParams.append('aid', aid);
|
||||
urlObj.searchParams.append('did', did);
|
||||
if (isThumb !== undefined && isThumb !== null) urlObj.searchParams.append('isThumb', `${isThumb}`);
|
||||
if (isWeb !== undefined && isWeb !== null) urlObj.searchParams.append('isWeb', `${isWeb}`);
|
||||
|
||||
return urlObj.href;
|
||||
}
|
||||
6
web/src/app.d.ts
vendored
6
web/src/app.d.ts
vendored
@@ -8,10 +8,4 @@ declare namespace App {
|
||||
}
|
||||
|
||||
// interface Platform {}
|
||||
|
||||
interface Session {
|
||||
user?: import('@api').UserResponseDto;
|
||||
}
|
||||
|
||||
// interface Stuff {}
|
||||
}
|
||||
|
||||
@@ -1,31 +1,5 @@
|
||||
import type { ExternalFetch, GetSession, Handle } from '@sveltejs/kit';
|
||||
import * as cookie from 'cookie';
|
||||
import { serverApi } from '@api';
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
const cookies = cookie.parse(event.request.headers.get('cookie') || '');
|
||||
|
||||
if (!cookies['immich_is_authenticated']) {
|
||||
return await resolve(event);
|
||||
}
|
||||
const accessToken = cookies['immich_access_token'];
|
||||
|
||||
try {
|
||||
serverApi.setAccessToken(accessToken);
|
||||
const { data } = await serverApi.userApi.getMyUserInfo();
|
||||
event.locals.user = data;
|
||||
|
||||
return await resolve(event);
|
||||
} catch (error) {
|
||||
event.locals.user = undefined;
|
||||
return await resolve(event);
|
||||
}
|
||||
};
|
||||
|
||||
export const getSession: GetSession = async ({ locals }) => {
|
||||
if (!locals.user) return {};
|
||||
|
||||
return {
|
||||
user: locals.user
|
||||
};
|
||||
return await resolve(event);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { AlbumResponseDto, api, ThumbnailFormat } from '@api';
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import DotsVertical from 'svelte-material-icons/DotsVertical.svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { fly } from 'svelte/transition';
|
||||
import CircleIconButton from '../shared-components/circle-icon-button.svelte';
|
||||
|
||||
export let album: AlbumResponseDto;
|
||||
|
||||
@@ -22,6 +22,10 @@
|
||||
import MenuOption from '../shared-components/context-menu/menu-option.svelte';
|
||||
import ThumbnailSelection from './thumbnail-selection.svelte';
|
||||
import ControlAppBar from '../shared-components/control-app-bar.svelte';
|
||||
import {
|
||||
notificationController,
|
||||
NotificationType
|
||||
} from '../shared-components/notification/notification';
|
||||
|
||||
export let album: AlbumResponseDto;
|
||||
|
||||
@@ -129,7 +133,11 @@
|
||||
album = data;
|
||||
multiSelectAsset = new Set();
|
||||
} catch (e) {
|
||||
console.log('Error [album-viewer] [removeAssetFromAlbum]', e);
|
||||
console.error('Error [album-viewer] [removeAssetFromAlbum]', e);
|
||||
notificationController.show({
|
||||
type: NotificationType.Error,
|
||||
message: 'Error removing assets from album, check console for more details'
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -179,7 +187,11 @@
|
||||
currentAlbumName = album.albumName;
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log('Error [updateAlbumInfo] ', e);
|
||||
console.error('Error [updateAlbumInfo] ', e);
|
||||
notificationController.show({
|
||||
type: NotificationType.Error,
|
||||
message: "Error updating album's name, check console for more details"
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -193,7 +205,11 @@
|
||||
|
||||
isShowAssetSelection = false;
|
||||
} catch (e) {
|
||||
console.log('Error [createAlbumHandler] ', e);
|
||||
console.error('Error [createAlbumHandler] ', e);
|
||||
notificationController.show({
|
||||
type: NotificationType.Error,
|
||||
message: 'Error creating album, check console for more details'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -209,7 +225,11 @@
|
||||
|
||||
isShowShareUserSelection = false;
|
||||
} catch (e) {
|
||||
console.log('Error [createAlbumHandler] ', e);
|
||||
console.error('Error [addUserHandler] ', e);
|
||||
notificationController.show({
|
||||
type: NotificationType.Error,
|
||||
message: 'Error adding users to album, check console for more details'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -227,7 +247,11 @@
|
||||
album = data;
|
||||
isShowShareInfoModal = false;
|
||||
} catch (e) {
|
||||
console.log('Error [sharedUserDeletedHandler] ', e);
|
||||
console.error('Error [sharedUserDeletedHandler] ', e);
|
||||
notificationController.show({
|
||||
type: NotificationType.Error,
|
||||
message: 'Error deleting share users, check console for more details'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -241,7 +265,11 @@
|
||||
await api.albumApi.deleteAlbum(album.id);
|
||||
goto(backUrl);
|
||||
} catch (e) {
|
||||
console.log('Error [userDeleteMenu] ', e);
|
||||
console.error('Error [userDeleteMenu] ', e);
|
||||
notificationController.show({
|
||||
type: NotificationType.Error,
|
||||
message: 'Error deleting album, check console for more details'
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -262,7 +290,11 @@
|
||||
albumThumbnailAssetId: asset.id
|
||||
});
|
||||
} catch (e) {
|
||||
console.log('Error [setAlbumThumbnailHandler] ', e);
|
||||
console.error('Error [setAlbumThumbnailHandler] ', e);
|
||||
notificationController.show({
|
||||
type: NotificationType.Error,
|
||||
message: 'Error setting album thumbnail, check console for more details'
|
||||
});
|
||||
}
|
||||
|
||||
isShowThumbnailSelection = false;
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
import CircleIconButton from '../shared-components/circle-icon-button.svelte';
|
||||
import ContextMenu from '../shared-components/context-menu/context-menu.svelte';
|
||||
import MenuOption from '../shared-components/context-menu/menu-option.svelte';
|
||||
import {
|
||||
notificationController,
|
||||
NotificationType
|
||||
} from '../shared-components/notification/notification';
|
||||
|
||||
export let album: AlbumResponseDto;
|
||||
|
||||
@@ -24,6 +28,10 @@
|
||||
currentUser = data;
|
||||
} catch (e) {
|
||||
console.error('Error [share-info-modal] [getAllUsers]', e);
|
||||
notificationController.show({
|
||||
message: 'Error getting user info, check console for more details',
|
||||
type: NotificationType.Error
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -48,6 +56,10 @@
|
||||
dispatch('user-deleted', { userId });
|
||||
} catch (e) {
|
||||
console.error('Error [share-info-modal] [removeUser]', e);
|
||||
notificationController.show({
|
||||
message: 'Error removing user, check console for more details',
|
||||
type: NotificationType.Error
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,207 +1,215 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import { fly } from 'svelte/transition';
|
||||
import AsserViewerNavBar from './asser-viewer-nav-bar.svelte';
|
||||
import ChevronRight from 'svelte-material-icons/ChevronRight.svelte';
|
||||
import ChevronLeft from 'svelte-material-icons/ChevronLeft.svelte';
|
||||
import PhotoViewer from './photo-viewer.svelte';
|
||||
import DetailPanel from './detail-panel.svelte';
|
||||
import { downloadAssets } from '$lib/stores/download';
|
||||
import VideoViewer from './video-viewer.svelte';
|
||||
import { api, AssetResponseDto, AssetTypeEnum } from '@api';
|
||||
import { browser } from '$app/env';
|
||||
import { createEventDispatcher, onMount, onDestroy } from 'svelte';
|
||||
import { fly } from 'svelte/transition';
|
||||
import AsserViewerNavBar from './asser-viewer-nav-bar.svelte';
|
||||
import ChevronRight from 'svelte-material-icons/ChevronRight.svelte';
|
||||
import ChevronLeft from 'svelte-material-icons/ChevronLeft.svelte';
|
||||
import PhotoViewer from './photo-viewer.svelte';
|
||||
import DetailPanel from './detail-panel.svelte';
|
||||
import { downloadAssets } from '$lib/stores/download';
|
||||
import VideoViewer from './video-viewer.svelte';
|
||||
import { api, AssetResponseDto, AssetTypeEnum } from '@api';
|
||||
import {
|
||||
notificationController,
|
||||
NotificationType
|
||||
} from '../shared-components/notification/notification';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
export let asset: AssetResponseDto;
|
||||
|
||||
export let asset: AssetResponseDto;
|
||||
const dispatch = createEventDispatcher();
|
||||
let halfLeftHover = false;
|
||||
let halfRightHover = false;
|
||||
let isShowDetail = false;
|
||||
|
||||
let halfLeftHover = false;
|
||||
let halfRightHover = false;
|
||||
let isShowDetail = false;
|
||||
onMount(() => {
|
||||
document.addEventListener('keydown', (keyInfo) => handleKeyboardPress(keyInfo.key));
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
if (browser) {
|
||||
document.addEventListener('keydown', (keyInfo) => handleKeyboardPress(keyInfo.key));
|
||||
}
|
||||
});
|
||||
onDestroy(() => {
|
||||
document.removeEventListener('keydown', (e) => {});
|
||||
});
|
||||
|
||||
const handleKeyboardPress = (key: string) => {
|
||||
switch (key) {
|
||||
case 'Escape':
|
||||
closeViewer();
|
||||
return;
|
||||
case 'i':
|
||||
isShowDetail = !isShowDetail;
|
||||
return;
|
||||
case 'ArrowLeft':
|
||||
navigateAssetBackward();
|
||||
return;
|
||||
case 'ArrowRight':
|
||||
navigateAssetForward();
|
||||
return;
|
||||
}
|
||||
};
|
||||
const handleKeyboardPress = (key: string) => {
|
||||
switch (key) {
|
||||
case 'Escape':
|
||||
closeViewer();
|
||||
return;
|
||||
case 'i':
|
||||
isShowDetail = !isShowDetail;
|
||||
return;
|
||||
case 'ArrowLeft':
|
||||
navigateAssetBackward();
|
||||
return;
|
||||
case 'ArrowRight':
|
||||
navigateAssetForward();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const closeViewer = () => {
|
||||
dispatch('close');
|
||||
};
|
||||
const closeViewer = () => {
|
||||
dispatch('close');
|
||||
};
|
||||
|
||||
const navigateAssetForward = (e?: Event) => {
|
||||
e?.stopPropagation();
|
||||
dispatch('navigate-forward');
|
||||
};
|
||||
const navigateAssetForward = (e?: Event) => {
|
||||
e?.stopPropagation();
|
||||
dispatch('navigate-forward');
|
||||
};
|
||||
|
||||
const navigateAssetBackward = (e?: Event) => {
|
||||
e?.stopPropagation();
|
||||
dispatch('navigate-backward');
|
||||
};
|
||||
const navigateAssetBackward = (e?: Event) => {
|
||||
e?.stopPropagation();
|
||||
dispatch('navigate-backward');
|
||||
};
|
||||
|
||||
const showDetailInfoHandler = () => {
|
||||
isShowDetail = !isShowDetail;
|
||||
};
|
||||
const showDetailInfoHandler = () => {
|
||||
isShowDetail = !isShowDetail;
|
||||
};
|
||||
|
||||
const downloadFile = async () => {
|
||||
try {
|
||||
console.log(asset.exifInfo);
|
||||
const imageName = asset.exifInfo?.imageName ? asset.exifInfo?.imageName : asset.id;
|
||||
const imageExtension = asset.originalPath.split('.')[1];
|
||||
const imageFileName = imageName + '.' + imageExtension;
|
||||
const downloadFile = async () => {
|
||||
try {
|
||||
console.log(asset.exifInfo);
|
||||
const imageName = asset.exifInfo?.imageName ? asset.exifInfo?.imageName : asset.id;
|
||||
const imageExtension = asset.originalPath.split('.')[1];
|
||||
const imageFileName = imageName + '.' + imageExtension;
|
||||
|
||||
// If assets is already download -> return;
|
||||
if ($downloadAssets[imageFileName]) {
|
||||
return;
|
||||
}
|
||||
// If assets is already download -> return;
|
||||
if ($downloadAssets[imageFileName]) {
|
||||
return;
|
||||
}
|
||||
|
||||
$downloadAssets[imageFileName] = 0;
|
||||
$downloadAssets[imageFileName] = 0;
|
||||
|
||||
const {data, status} = await api.assetApi.downloadFile(
|
||||
asset.deviceAssetId,
|
||||
asset.deviceId,
|
||||
false,
|
||||
false,
|
||||
{
|
||||
responseType: 'blob',
|
||||
onDownloadProgress: (progressEvent) => {
|
||||
if (progressEvent.lengthComputable) {
|
||||
const total = progressEvent.total;
|
||||
const current = progressEvent.loaded;
|
||||
$downloadAssets[imageFileName] = Math.floor((current / total) * 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
const { data, status } = await api.assetApi.downloadFile(
|
||||
asset.deviceAssetId,
|
||||
asset.deviceId,
|
||||
false,
|
||||
false,
|
||||
{
|
||||
responseType: 'blob',
|
||||
onDownloadProgress: (progressEvent) => {
|
||||
if (progressEvent.lengthComputable) {
|
||||
const total = progressEvent.total;
|
||||
const current = progressEvent.loaded;
|
||||
$downloadAssets[imageFileName] = Math.floor((current / total) * 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (!(data instanceof Blob)) {
|
||||
return;
|
||||
}
|
||||
if (!(data instanceof Blob)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === 200) {
|
||||
const fileUrl = URL.createObjectURL(data);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = fileUrl;
|
||||
anchor.download = imageFileName;
|
||||
if (status === 200) {
|
||||
const fileUrl = URL.createObjectURL(data);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = fileUrl;
|
||||
anchor.download = imageFileName;
|
||||
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
document.body.removeChild(anchor);
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
document.body.removeChild(anchor);
|
||||
|
||||
URL.revokeObjectURL(fileUrl);
|
||||
URL.revokeObjectURL(fileUrl);
|
||||
|
||||
// Remove item from download list
|
||||
setTimeout(() => {
|
||||
const copy = $downloadAssets;
|
||||
delete copy[imageFileName];
|
||||
$downloadAssets = copy;
|
||||
}, 2000);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Error downloading file ', e);
|
||||
}
|
||||
};
|
||||
// Remove item from download list
|
||||
setTimeout(() => {
|
||||
const copy = $downloadAssets;
|
||||
delete copy[imageFileName];
|
||||
$downloadAssets = copy;
|
||||
}, 2000);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error downloading file ', e);
|
||||
notificationController.show({
|
||||
type: NotificationType.Error,
|
||||
message: 'Error downloading file, check console for more details.'
|
||||
});
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<section
|
||||
id="immich-asset-viewer"
|
||||
class="fixed h-screen w-screen top-0 overflow-y-hidden bg-black z-[999] grid grid-rows-[64px_1fr] grid-cols-4 "
|
||||
id="immich-asset-viewer"
|
||||
class="fixed h-screen w-screen top-0 overflow-y-hidden bg-black z-[999] grid grid-rows-[64px_1fr] grid-cols-4 "
|
||||
>
|
||||
<div class="col-start-1 col-span-4 row-start-1 row-span-1 z-[1000] transition-transform">
|
||||
<AsserViewerNavBar
|
||||
on:goBack={closeViewer}
|
||||
on:showDetail={showDetailInfoHandler}
|
||||
on:download={downloadFile}
|
||||
/>
|
||||
</div>
|
||||
<div class="col-start-1 col-span-4 row-start-1 row-span-1 z-[1000] transition-transform">
|
||||
<AsserViewerNavBar
|
||||
on:goBack={closeViewer}
|
||||
on:showDetail={showDetailInfoHandler}
|
||||
on:download={downloadFile}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class={`row-start-2 row-span-end col-start-1 col-span-2 flex place-items-center hover:cursor-pointer w-3/4 ${
|
||||
<div
|
||||
class={`row-start-2 row-span-end col-start-1 col-span-2 flex place-items-center hover:cursor-pointer w-3/4 ${
|
||||
asset.type === AssetTypeEnum.Video ? '' : 'z-[999]'
|
||||
}`}
|
||||
on:mouseenter={() => {
|
||||
on:mouseenter={() => {
|
||||
halfLeftHover = true;
|
||||
halfRightHover = false;
|
||||
}}
|
||||
on:mouseleave={() => {
|
||||
on:mouseleave={() => {
|
||||
halfLeftHover = false;
|
||||
}}
|
||||
on:click={navigateAssetBackward}
|
||||
>
|
||||
<button
|
||||
class="rounded-full p-3 hover:bg-gray-500 hover:text-gray-700 z-[1000] text-gray-500 mx-4"
|
||||
class:navigation-button-hover={halfLeftHover}
|
||||
on:click={navigateAssetBackward}
|
||||
>
|
||||
<ChevronLeft size="36"/>
|
||||
</button>
|
||||
</div>
|
||||
on:click={navigateAssetBackward}
|
||||
>
|
||||
<button
|
||||
class="rounded-full p-3 hover:bg-gray-500 hover:text-gray-700 z-[1000] text-gray-500 mx-4"
|
||||
class:navigation-button-hover={halfLeftHover}
|
||||
on:click={navigateAssetBackward}
|
||||
>
|
||||
<ChevronLeft size="36" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="row-start-1 row-span-full col-start-1 col-span-4">
|
||||
{#key asset.id}
|
||||
{#if asset.type === AssetTypeEnum.Image}
|
||||
<PhotoViewer assetId={asset.id} deviceId={asset.deviceId} on:close={closeViewer}/>
|
||||
{:else}
|
||||
<VideoViewer assetId={asset.id} on:close={closeViewer}/>
|
||||
{/if}
|
||||
{/key}
|
||||
</div>
|
||||
<div class="row-start-1 row-span-full col-start-1 col-span-4">
|
||||
{#key asset.id}
|
||||
{#if asset.type === AssetTypeEnum.Image}
|
||||
<PhotoViewer assetId={asset.id} deviceId={asset.deviceId} on:close={closeViewer} />
|
||||
{:else}
|
||||
<VideoViewer assetId={asset.id} on:close={closeViewer} />
|
||||
{/if}
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class={`row-start-2 row-span-full col-start-3 col-span-2 flex justify-end place-items-center hover:cursor-pointer w-3/4 justify-self-end ${
|
||||
<div
|
||||
class={`row-start-2 row-span-full col-start-3 col-span-2 flex justify-end place-items-center hover:cursor-pointer w-3/4 justify-self-end ${
|
||||
asset.type === AssetTypeEnum.Video ? '' : 'z-[500]'
|
||||
}`}
|
||||
on:click={navigateAssetForward}
|
||||
on:mouseenter={() => {
|
||||
on:click={navigateAssetForward}
|
||||
on:mouseenter={() => {
|
||||
halfLeftHover = false;
|
||||
halfRightHover = true;
|
||||
}}
|
||||
on:mouseleave={() => {
|
||||
on:mouseleave={() => {
|
||||
halfRightHover = false;
|
||||
}}
|
||||
>
|
||||
<button
|
||||
class="rounded-full p-3 hover:bg-gray-500 hover:text-gray-700 text-gray-500 mx-4 z-[1000]"
|
||||
class:navigation-button-hover={halfRightHover}
|
||||
on:click={navigateAssetForward}
|
||||
>
|
||||
<ChevronRight size="36"/>
|
||||
</button>
|
||||
</div>
|
||||
>
|
||||
<button
|
||||
class="rounded-full p-3 hover:bg-gray-500 hover:text-gray-700 text-gray-500 mx-4 z-[1000]"
|
||||
class:navigation-button-hover={halfRightHover}
|
||||
on:click={navigateAssetForward}
|
||||
>
|
||||
<ChevronRight size="36" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if isShowDetail}
|
||||
<div
|
||||
transition:fly={{ duration: 150 }}
|
||||
id="detail-panel"
|
||||
class="bg-immich-bg w-[360px] row-span-full transition-all "
|
||||
translate="yes"
|
||||
>
|
||||
<DetailPanel {asset} on:close={() => (isShowDetail = false)}/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if isShowDetail}
|
||||
<div
|
||||
transition:fly={{ duration: 150 }}
|
||||
id="detail-panel"
|
||||
class="bg-immich-bg w-[360px] row-span-full transition-all "
|
||||
translate="yes"
|
||||
>
|
||||
<DetailPanel {asset} on:close={() => (isShowDetail = false)} />
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.navigation-button-hover {
|
||||
background-color: rgb(107 114 128 / var(--tw-bg-opacity));
|
||||
color: rgb(55 65 81 / var(--tw-text-opacity));
|
||||
transition: all 150ms;
|
||||
}
|
||||
.navigation-button-hover {
|
||||
background-color: rgb(107 114 128 / var(--tw-bg-opacity));
|
||||
color: rgb(55 65 81 / var(--tw-text-opacity));
|
||||
transition: all 150ms;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
||||
import { api, AssetResponseDto } from '@api';
|
||||
|
||||
@@ -10,8 +10,6 @@
|
||||
|
||||
let assetInfo: AssetResponseDto;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
onMount(async () => {
|
||||
const { data } = await api.assetApi.getAssetById(assetId);
|
||||
assetInfo = data;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
||||
import { api, AssetResponseDto } from '@api';
|
||||
import { api, AssetResponseDto, getFileUrl } from '@api';
|
||||
|
||||
export let assetId: string;
|
||||
|
||||
@@ -13,48 +13,32 @@
|
||||
|
||||
let videoPlayerNode: HTMLVideoElement;
|
||||
let isVideoLoading = true;
|
||||
let videoUrl: string;
|
||||
|
||||
onMount(async () => {
|
||||
const { data: assetInfo } = await api.assetApi.getAssetById(assetId);
|
||||
|
||||
asset = assetInfo;
|
||||
await loadVideoData(assetInfo);
|
||||
|
||||
await loadVideoData();
|
||||
asset = assetInfo;
|
||||
});
|
||||
|
||||
const loadVideoData = async () => {
|
||||
const loadVideoData = async (assetInfo: AssetResponseDto) => {
|
||||
isVideoLoading = true;
|
||||
|
||||
try {
|
||||
const { data } = await api.assetApi.serveFile(
|
||||
asset.deviceAssetId,
|
||||
asset.deviceId,
|
||||
false,
|
||||
true,
|
||||
{
|
||||
responseType: 'blob'
|
||||
}
|
||||
);
|
||||
videoUrl = getFileUrl(assetInfo.deviceAssetId, assetInfo.deviceId, false, true);
|
||||
|
||||
if (!(data instanceof Blob)) {
|
||||
return;
|
||||
}
|
||||
return assetInfo;
|
||||
};
|
||||
|
||||
const videoData = URL.createObjectURL(data);
|
||||
videoPlayerNode.src = videoData;
|
||||
const handleCanPlay = (ev: Event) => {
|
||||
const playerNode = ev.target as HTMLVideoElement;
|
||||
|
||||
videoPlayerNode.load();
|
||||
playerNode.muted = true;
|
||||
playerNode.play();
|
||||
playerNode.muted = false;
|
||||
|
||||
videoPlayerNode.oncanplay = () => {
|
||||
videoPlayerNode.muted = true;
|
||||
videoPlayerNode.play();
|
||||
videoPlayerNode.muted = false;
|
||||
|
||||
isVideoLoading = false;
|
||||
};
|
||||
|
||||
return videoData;
|
||||
} catch (e) {}
|
||||
isVideoLoading = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -63,7 +47,13 @@
|
||||
class="flex place-items-center place-content-center h-full select-none"
|
||||
>
|
||||
{#if asset}
|
||||
<video controls class="h-full object-contain" bind:this={videoPlayerNode}>
|
||||
<video
|
||||
controls
|
||||
class="h-full object-contain"
|
||||
on:canplay={handleCanPlay}
|
||||
bind:this={videoPlayerNode}
|
||||
>
|
||||
<source src={videoUrl} type="video/mp4" />
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
import { api, UserResponseDto } from '@api';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import AccountEditOutline from 'svelte-material-icons/AccountEditOutline.svelte';
|
||||
import {
|
||||
notificationController,
|
||||
NotificationType
|
||||
} from '../shared-components/notification/notification';
|
||||
|
||||
export let user: UserResponseDto;
|
||||
|
||||
@@ -29,7 +33,11 @@
|
||||
dispatch('edit-success');
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Error updating user ', e);
|
||||
console.error('Error updating user ', e);
|
||||
notificationController.show({
|
||||
message: 'Error updating user, check console for more details',
|
||||
type: NotificationType.Error
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -49,7 +57,11 @@
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Error reseting user password', e);
|
||||
console.error('Error reseting user password', e);
|
||||
notificationController.show({
|
||||
message: 'Error reseting user password, check console for more details',
|
||||
type: NotificationType.Error
|
||||
});
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -8,19 +8,25 @@
|
||||
export let size: number = 48;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
const getUserAvatar = async () => {
|
||||
try {
|
||||
const { data } = await api.userApi.getProfileImage(user.id, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
if (data instanceof Blob) {
|
||||
return URL.createObjectURL(data);
|
||||
}
|
||||
} catch (e) {
|
||||
return '/favicon.png';
|
||||
const getUserAvatar = async () => {
|
||||
const { data } = await api.userApi.getProfileImage(user.id, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
if (data instanceof Blob) {
|
||||
return URL.createObjectURL(data);
|
||||
}
|
||||
};
|
||||
|
||||
const getFirstLetter = (text?: string) => {
|
||||
return text?.charAt(0).toUpperCase();
|
||||
};
|
||||
|
||||
const getRandomeBackgroundColor = () => {
|
||||
const colors = ['#DE7FB3', '#E64132', '#FFB800', '#4081EF', '#31A452'];
|
||||
return colors[Math.floor(Math.random() * colors.length)];
|
||||
};
|
||||
</script>
|
||||
|
||||
{#await getUserAvatar()}
|
||||
@@ -41,4 +47,17 @@
|
||||
title={user.email}
|
||||
/>
|
||||
</button>
|
||||
{:catch}
|
||||
<button
|
||||
on:click={() => dispatch('click')}
|
||||
style:width={`${size}px`}
|
||||
style:height={`${size}px`}
|
||||
style:background-color={getRandomeBackgroundColor()}
|
||||
alt="profile-img"
|
||||
class="inline rounded-full object-cover shadow-sm text-white font-semibold"
|
||||
>
|
||||
<div title={user.email}>
|
||||
{getFirstLetter(user.firstName)}{getFirstLetter(user.lastName)}
|
||||
</div>
|
||||
</button>
|
||||
{/await}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import PlayCircleOutline from 'svelte-material-icons/PlayCircleOutline.svelte';
|
||||
import PauseCircleOutline from 'svelte-material-icons/PauseCircleOutline.svelte';
|
||||
import LoadingSpinner from './loading-spinner.svelte';
|
||||
import { api, AssetResponseDto, AssetTypeEnum, ThumbnailFormat } from '@api';
|
||||
import { api, AssetResponseDto, AssetTypeEnum, getFileUrl, ThumbnailFormat } from '@api';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
export let isExisted: boolean = false;
|
||||
|
||||
let imageData: string;
|
||||
let videoData: string;
|
||||
// let videoData: string;
|
||||
|
||||
let mouseOver: boolean = false;
|
||||
$: dispatch('mouseEvent', { isMouseOver: mouseOver, selectedGroupIndex: groupIndex });
|
||||
@@ -28,7 +28,8 @@
|
||||
let isThumbnailVideoPlaying = false;
|
||||
let calculateVideoDurationIntervalHandler: NodeJS.Timer;
|
||||
let videoProgress = '00:00';
|
||||
let videoAbortController: AbortController;
|
||||
// let videoAbortController: AbortController;
|
||||
let videoUrl: string;
|
||||
|
||||
const loadImageData = async () => {
|
||||
const { data } = await api.assetApi.getAssetThumbnail(asset.id, format, {
|
||||
@@ -42,51 +43,8 @@
|
||||
|
||||
const loadVideoData = async () => {
|
||||
isThumbnailVideoPlaying = false;
|
||||
videoAbortController = new AbortController();
|
||||
|
||||
try {
|
||||
const { data } = await api.assetApi.serveFile(
|
||||
asset.deviceAssetId,
|
||||
asset.deviceId,
|
||||
false,
|
||||
true,
|
||||
{
|
||||
responseType: 'blob',
|
||||
signal: videoAbortController.signal
|
||||
}
|
||||
);
|
||||
|
||||
if (!(data instanceof Blob)) {
|
||||
return;
|
||||
}
|
||||
|
||||
videoData = URL.createObjectURL(data);
|
||||
|
||||
videoPlayerNode.src = videoData;
|
||||
|
||||
videoPlayerNode.load();
|
||||
|
||||
videoPlayerNode.onloadeddata = () => {
|
||||
console.log('first frame load');
|
||||
};
|
||||
|
||||
videoPlayerNode.oncanplaythrough = () => {
|
||||
console.log('can play through');
|
||||
};
|
||||
|
||||
videoPlayerNode.oncanplay = () => {
|
||||
console.log('can play');
|
||||
videoPlayerNode.muted = true;
|
||||
videoPlayerNode.play();
|
||||
|
||||
isThumbnailVideoPlaying = true;
|
||||
calculateVideoDurationIntervalHandler = setInterval(() => {
|
||||
videoProgress = getVideoDurationInString(Math.round(videoPlayerNode.currentTime));
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
return videoData;
|
||||
} catch (e) {}
|
||||
videoUrl = getFileUrl(asset.deviceAssetId, asset.deviceId, false, true);
|
||||
};
|
||||
|
||||
const getVideoDurationInString = (currentTime: number) => {
|
||||
@@ -136,12 +94,7 @@
|
||||
|
||||
const handleMouseLeaveThumbnail = () => {
|
||||
mouseOver = false;
|
||||
|
||||
// Stop XHR download of video
|
||||
videoAbortController?.abort();
|
||||
|
||||
// Stop video playback
|
||||
URL.revokeObjectURL(videoData);
|
||||
videoUrl = '';
|
||||
|
||||
clearInterval(calculateVideoDurationIntervalHandler);
|
||||
|
||||
@@ -149,6 +102,18 @@
|
||||
videoProgress = '00:00';
|
||||
};
|
||||
|
||||
const handleCanPlay = (ev: Event) => {
|
||||
const playerNode = ev.target as HTMLVideoElement;
|
||||
|
||||
playerNode.muted = true;
|
||||
playerNode.play();
|
||||
|
||||
isThumbnailVideoPlaying = true;
|
||||
calculateVideoDurationIntervalHandler = setInterval(() => {
|
||||
videoProgress = getVideoDurationInString(Math.round(playerNode.currentTime));
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
$: getThumbnailBorderStyle = () => {
|
||||
if (selected) {
|
||||
return 'border-[20px] border-immich-primary/20';
|
||||
@@ -259,17 +224,21 @@
|
||||
|
||||
{#if mouseOver && asset.type === AssetTypeEnum.Video}
|
||||
<div class="absolute w-full h-full top-0" on:mouseenter={loadVideoData}>
|
||||
<video
|
||||
muted
|
||||
autoplay
|
||||
preload="none"
|
||||
class="h-full object-cover"
|
||||
width="250px"
|
||||
style:width={`${thumbnailSize}px`}
|
||||
bind:this={videoPlayerNode}
|
||||
>
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
{#if videoUrl}
|
||||
<video
|
||||
muted
|
||||
autoplay
|
||||
preload="none"
|
||||
class="h-full object-cover"
|
||||
width="250px"
|
||||
style:width={`${thumbnailSize}px`}
|
||||
on:canplay={handleCanPlay}
|
||||
bind:this={videoPlayerNode}
|
||||
>
|
||||
<source src={videoUrl} type="video/mp4" />
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import type { ImmichUser } from '$lib/models/immich-user';
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import { fade, fly, slide } from 'svelte/transition';
|
||||
import TrayArrowUp from 'svelte-material-icons/TrayArrowUp.svelte';
|
||||
import { clickOutside } from '../../utils/click-outside';
|
||||
import { api } from '@api';
|
||||
import { api, UserResponseDto } from '@api';
|
||||
|
||||
export let user: ImmichUser;
|
||||
export let user: UserResponseDto;
|
||||
export let shouldShowUploadButton = true;
|
||||
|
||||
let shouldShowAccountInfo = false;
|
||||
let shouldShowProfileImage = false;
|
||||
@@ -25,7 +25,6 @@
|
||||
await api.userApi.getProfileImage(user.id);
|
||||
shouldShowProfileImage = true;
|
||||
} catch (e) {
|
||||
console.log('User does not have a profile image');
|
||||
shouldShowProfileImage = false;
|
||||
}
|
||||
};
|
||||
@@ -33,10 +32,6 @@
|
||||
return text?.charAt(0).toUpperCase();
|
||||
};
|
||||
|
||||
const navigateToAdmin = () => {
|
||||
goto('/admin');
|
||||
};
|
||||
|
||||
const showAccountInfoPanel = () => {
|
||||
shouldShowAccountInfoPanel = true;
|
||||
};
|
||||
@@ -60,7 +55,7 @@
|
||||
/>
|
||||
</div>
|
||||
<section class="flex gap-4 place-items-center">
|
||||
{#if $page.url.pathname !== '/admin'}
|
||||
{#if $page.url.pathname !== '/admin' && shouldShowUploadButton}
|
||||
<button
|
||||
in:fly={{ x: 50, duration: 250 }}
|
||||
on:click={() => dispatch('uploadClicked')}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { tweened } from 'svelte/motion';
|
||||
|
||||
const progress = tweened(0, {
|
||||
duration: 1000,
|
||||
easing: cubicOut
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
progress.set(90);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="absolute top-0 left-0 w-screen h-[3px] bg-white z-[999999999]">
|
||||
<span class="absolute bg-immich-primary h-[3px]" style:width={`${$progress}%`} />
|
||||
</div>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition';
|
||||
import CloseCircleOutline from 'svelte-material-icons/CloseCircleOutline.svelte';
|
||||
import InformationOutline from 'svelte-material-icons/InformationOutline.svelte';
|
||||
|
||||
import {
|
||||
ImmichNotification,
|
||||
notificationController,
|
||||
NotificationType
|
||||
} from '$lib/components/shared-components/notification/notification';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
export let notificationInfo: ImmichNotification;
|
||||
|
||||
let infoPrimaryColor = '#4250AF';
|
||||
let errorPrimaryColor = '#E64132';
|
||||
|
||||
$: icon =
|
||||
notificationInfo.type === NotificationType.Error ? CloseCircleOutline : InformationOutline;
|
||||
|
||||
$: backgroundColor = () => {
|
||||
if (notificationInfo.type === NotificationType.Info) {
|
||||
return '#E0E2F0';
|
||||
}
|
||||
|
||||
if (notificationInfo.type === NotificationType.Error) {
|
||||
return '#FBE8E6';
|
||||
}
|
||||
};
|
||||
|
||||
$: borderStyle = () => {
|
||||
if (notificationInfo.type === NotificationType.Info) {
|
||||
return '1px solid #D8DDFF';
|
||||
}
|
||||
|
||||
if (notificationInfo.type === NotificationType.Error) {
|
||||
return '1px solid #F0E8E7';
|
||||
}
|
||||
};
|
||||
|
||||
$: primaryColor = () => {
|
||||
if (notificationInfo.type === NotificationType.Info) {
|
||||
return infoPrimaryColor;
|
||||
}
|
||||
|
||||
if (notificationInfo.type === NotificationType.Error) {
|
||||
return errorPrimaryColor;
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
setTimeout(() => {
|
||||
notificationController.removeNotificationById(notificationInfo.id);
|
||||
}, notificationInfo.timeout);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
transition:fade={{ duration: 250 }}
|
||||
style:background-color={backgroundColor()}
|
||||
style:border={borderStyle()}
|
||||
class="min-h-[80px] w-[300px] rounded-2xl z-[999999] shadow-md p-4 mb-4"
|
||||
>
|
||||
<div class="flex gap-2 place-items-center">
|
||||
<svelte:component this={icon} color={primaryColor()} size="20" />
|
||||
<h2 style:color={primaryColor()} class="font-medium">{notificationInfo.type.toString()}</h2>
|
||||
</div>
|
||||
|
||||
<p class="text-sm pl-[28px] pr-[16px]">{notificationInfo.message}</p>
|
||||
</div>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { ImmichNotification, notificationController } from './notification';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
import NotificationCard from './notification-card.svelte';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
|
||||
let notificationList: ImmichNotification[] = [];
|
||||
|
||||
notificationController.notificationList.subscribe((list) => {
|
||||
notificationList = list;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if notificationList.length > 0}
|
||||
<section
|
||||
transition:fade={{ duration: 250 }}
|
||||
id="notification-list"
|
||||
class="absolute right-5 top-[80px] z-[99999999]"
|
||||
>
|
||||
{#each notificationList as notificationInfo (notificationInfo.id)}
|
||||
<div animate:flip={{ duration: 250, easing: quintOut }}>
|
||||
<NotificationCard {notificationInfo} />
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
{/if}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export enum NotificationType {
|
||||
Info = 'Info',
|
||||
Error = 'Error'
|
||||
}
|
||||
|
||||
export class ImmichNotification {
|
||||
id = new Date().getTime();
|
||||
type!: NotificationType;
|
||||
message!: string;
|
||||
timeout = 3000;
|
||||
}
|
||||
|
||||
export class ImmichNotificationDto {
|
||||
/**
|
||||
* Notification type
|
||||
* @type {NotificationType} [Info, Error]
|
||||
*/
|
||||
type: NotificationType = NotificationType.Info;
|
||||
|
||||
/**
|
||||
* Notification message
|
||||
*/
|
||||
message = '';
|
||||
|
||||
/**
|
||||
* Timeout in miliseconds
|
||||
*/
|
||||
timeout?: number;
|
||||
}
|
||||
function createNotificationList() {
|
||||
const notificationList = writable<ImmichNotification[]>([]);
|
||||
|
||||
const show = (notificationInfo: ImmichNotificationDto) => {
|
||||
const newNotification = new ImmichNotification();
|
||||
newNotification.message = notificationInfo.message;
|
||||
newNotification.type = notificationInfo.type;
|
||||
newNotification.timeout = notificationInfo.timeout || 3000;
|
||||
|
||||
notificationList.update((currentList) => [...currentList, newNotification]);
|
||||
};
|
||||
|
||||
const removeNotificationById = (id: number) => {
|
||||
notificationList.update((currentList) => currentList.filter((n) => n.id != id));
|
||||
};
|
||||
|
||||
return {
|
||||
show,
|
||||
removeNotificationById,
|
||||
notificationList
|
||||
};
|
||||
}
|
||||
|
||||
export const notificationController = createNotificationList();
|
||||
@@ -1,6 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
import { AppSideBarSelection } from '$lib/models/admin-sidebar-selection';
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
@@ -24,7 +22,7 @@
|
||||
</script>
|
||||
|
||||
<section id="sidebar" class="flex flex-col gap-1 pt-8 pr-6">
|
||||
<a sveltekit:prefetch href={$page.routeId != 'photos' ? `/photos` : null}>
|
||||
<a sveltekit:prefetch sveltekit:noscroll href={$page.routeId !== 'photos' ? `/photos` : null}>
|
||||
<SideBarButton
|
||||
title="Photos"
|
||||
logo={ImageOutline}
|
||||
@@ -32,7 +30,7 @@
|
||||
isSelected={selectedAction === AppSideBarSelection.PHOTOS}
|
||||
/></a
|
||||
>
|
||||
<a sveltekit:prefetch href={$page.routeId != 'sharing' ? `/sharing` : null}>
|
||||
<a sveltekit:prefetch href={$page.routeId !== 'sharing' ? `/sharing` : null}>
|
||||
<SideBarButton
|
||||
title="Sharing"
|
||||
logo={AccountMultipleOutline}
|
||||
@@ -43,7 +41,7 @@
|
||||
<div class="text-xs ml-5 my-4">
|
||||
<p>LIBRARY</p>
|
||||
</div>
|
||||
<a sveltekit:prefetch href={$page.routeId != 'albums' ? `/albums` : null}>
|
||||
<a sveltekit:prefetch href={$page.routeId !== 'albums' ? `/albums` : null}>
|
||||
<SideBarButton
|
||||
title="Albums"
|
||||
logo={ImageAlbum}
|
||||
@@ -51,8 +49,8 @@
|
||||
isSelected={selectedAction === AppSideBarSelection.ALBUMS}
|
||||
/>
|
||||
</a>
|
||||
<!-- Status Box -->
|
||||
|
||||
<!-- Status Box -->
|
||||
<div class="mb-6 mt-auto">
|
||||
<StatusBox />
|
||||
</div>
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
const { data: serverInfoRes } = await api.serverInfoApi.getServerInfo();
|
||||
serverInfo = serverInfoRes;
|
||||
} catch (e) {
|
||||
console.log('Error [StatusBox] [pingServerInterval]');
|
||||
console.log('Error [StatusBox] [pingServerInterval]', e);
|
||||
isServerOk = false;
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
import WindowMinimize from 'svelte-material-icons/WindowMinimize.svelte';
|
||||
import type { UploadAsset } from '$lib/models/upload-asset';
|
||||
import { getAssetsInfo } from '$lib/stores/assets';
|
||||
import { session } from '$app/stores';
|
||||
let showDetail = true;
|
||||
|
||||
let uploadLength = 0;
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import {
|
||||
notificationController,
|
||||
NotificationType
|
||||
} from './../components/shared-components/notification/notification';
|
||||
/* @vite-ignore */
|
||||
import * as exifr from 'exifr';
|
||||
import { uploadAssetsStore } from '$lib/stores/upload';
|
||||
import type { UploadAsset } from '../models/upload-asset';
|
||||
import { api, AssetFileUploadResponseDto } from '@api';
|
||||
import { albumUploadAssetStore } from '$lib/stores/album-upload-asset';
|
||||
|
||||
/**
|
||||
* Determine if the upload is for album or for the user general backup
|
||||
* @variant GENERAL - Upload assets to the server for general backup
|
||||
@@ -28,11 +31,22 @@ export const openFileUploadDialog = (uploadType: UploadType) => {
|
||||
|
||||
fileSelector.type = 'file';
|
||||
fileSelector.multiple = true;
|
||||
fileSelector.accept = 'image/*,video/*,.heic,.heif';
|
||||
fileSelector.accept = 'image/*,video/*,.heic,.heif,.dng';
|
||||
|
||||
fileSelector.onchange = async (e: any) => {
|
||||
const files = Array.from<File>(e.target.files);
|
||||
|
||||
if (files.length > 50) {
|
||||
notificationController.show({
|
||||
type: NotificationType.Error,
|
||||
message: `Cannot upload more than 50 files at a time - you are uploading ${files.length} files.
|
||||
Please use the CLI tool if you need to upload more than 50 files.`,
|
||||
timeout: 5000
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const acceptedFile = files.filter(
|
||||
(e) => e.type.split('/')[0] === 'video' || e.type.split('/')[0] === 'image'
|
||||
);
|
||||
|
||||
29
web/src/routes/+error.svelte
Normal file
29
web/src/routes/+error.svelte
Normal file
@@ -0,0 +1,29 @@
|
||||
<script>
|
||||
import { page } from '$app/stores';
|
||||
</script>
|
||||
|
||||
<div class="h-screen w-screen flex place-items-center place-content-center flex-col">
|
||||
<div class="min-w-[500px] bg-gray-300 rounded-2xl my-4 p-4">
|
||||
<code class="text-xs text-red-500">Error code {$page.status}</code>
|
||||
<br />
|
||||
<code class="text-sm">
|
||||
{$page.error.message}
|
||||
</code>
|
||||
<br />
|
||||
<div class="mt-5">
|
||||
<p class="text-sm font-medium">Verbose</p>
|
||||
<pre class="text-xs">{Object.values($page.error)}</pre>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="https://github.com/immich-app/immich/issues/new/choose"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<button
|
||||
class="px-5 py-2 rounded-lg text-sm mt-6 bg-immich-primary text-white hover:bg-immich-primary/75"
|
||||
>Get help</button
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
26
web/src/routes/+layout.server.ts
Normal file
26
web/src/routes/+layout.server.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { serverApi } from '@api';
|
||||
import * as cookieParser from 'cookie';
|
||||
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ request }) => {
|
||||
try {
|
||||
const cookies = cookieParser.parse(request.headers.get('cookie') || '');
|
||||
const accessToken = cookies['immich_access_token'];
|
||||
|
||||
if (!accessToken) {
|
||||
return {
|
||||
user: undefined
|
||||
};
|
||||
}
|
||||
|
||||
serverApi.setAccessToken(accessToken);
|
||||
const { data: userInfo } = await serverApi.userApi.getMyUserInfo();
|
||||
|
||||
return {
|
||||
user: userInfo
|
||||
};
|
||||
} catch (e) {
|
||||
console.log('[ERROR] layout.server.ts [LayoutServerLoad]: ', e);
|
||||
}
|
||||
};
|
||||
@@ -1,14 +1,3 @@
|
||||
<script context="module" lang="ts">
|
||||
import type { Load } from '@sveltejs/kit';
|
||||
import { checkAppVersion } from '$lib/utils/check-app-version';
|
||||
|
||||
export const load: Load = async ({ url }) => {
|
||||
return {
|
||||
props: { url }
|
||||
};
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
|
||||
@@ -17,11 +6,16 @@
|
||||
import AnnouncementBox from '$lib/components/shared-components/announcement-box.svelte';
|
||||
import UploadPanel from '$lib/components/shared-components/upload-panel.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { checkAppVersion } from '$lib/utils/check-app-version';
|
||||
import { page } from '$app/stores';
|
||||
import { afterNavigate, beforeNavigate } from '$app/navigation';
|
||||
import NavigationLoadingBar from '$lib/components/shared-components/navigation-loading-bar.svelte';
|
||||
import NotificationList from '$lib/components/shared-components/notification/notification-list.svelte';
|
||||
|
||||
export let url: string;
|
||||
let shouldShowAnnouncement: boolean;
|
||||
let localVersion: string;
|
||||
let remoteVersion: string;
|
||||
let showNavigationLoadingBar = false;
|
||||
|
||||
onMount(async () => {
|
||||
const res = await checkAppVersion();
|
||||
@@ -30,16 +24,28 @@
|
||||
localVersion = res.localVersion ?? 'unknown';
|
||||
remoteVersion = res.remoteVersion ?? 'unknown';
|
||||
});
|
||||
|
||||
beforeNavigate(() => {
|
||||
showNavigationLoadingBar = true;
|
||||
});
|
||||
|
||||
afterNavigate(() => {
|
||||
showNavigationLoadingBar = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<main>
|
||||
{#key url}
|
||||
{#key $page.url}
|
||||
<div in:fade={{ duration: 100 }}>
|
||||
{#if showNavigationLoadingBar}
|
||||
<NavigationLoadingBar />
|
||||
{/if}
|
||||
|
||||
<slot />
|
||||
|
||||
<DownloadPanel />
|
||||
|
||||
<UploadPanel />
|
||||
|
||||
<NotificationList />
|
||||
{#if shouldShowAnnouncement}
|
||||
<AnnouncementBox
|
||||
{localVersion}
|
||||
29
web/src/routes/+page.svelte
Normal file
29
web/src/routes/+page.svelte
Normal file
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
async function onGettingStartedClicked() {
|
||||
data.isAdminUserExist ? await goto('/auth/login') : await goto('/auth/register');
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Welcome 🎉 - Immich</title>
|
||||
<meta name="description" content="Immich Web Interface" />
|
||||
</svelte:head>
|
||||
|
||||
<section class="h-screen w-screen flex place-items-center place-content-center">
|
||||
<div class="flex flex-col place-items-center gap-8 text-center max-w-[350px]">
|
||||
<div class="flex place-items-center place-content-center ">
|
||||
<img class="text-center" src="immich-logo.svg" height="200" width="200" alt="immich-logo" />
|
||||
</div>
|
||||
<h1 class="text-4xl text-immich-primary font-bold font-immich-title">Welcome to IMMICH Web</h1>
|
||||
<button
|
||||
class="border px-4 py-2 rounded-md bg-immich-primary hover:bg-immich-primary/75 text-white font-bold w-[200px]"
|
||||
on:click={onGettingStartedClicked}
|
||||
>Getting Started
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
20
web/src/routes/+page.ts
Normal file
20
web/src/routes/+page.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export const prerender = false;
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { api } from '@api';
|
||||
import { browser } from '$app/env';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = async ({ parent }) => {
|
||||
const { user } = await parent();
|
||||
if (user) {
|
||||
throw redirect(302, '/photos');
|
||||
}
|
||||
|
||||
if (browser) {
|
||||
const { data } = await api.userApi.getUserCount();
|
||||
|
||||
return {
|
||||
isAdminUserExist: data.userCount != 0
|
||||
};
|
||||
}
|
||||
};
|
||||
19
web/src/routes/admin/+page.server.ts
Normal file
19
web/src/routes/admin/+page.server.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { serverApi, UserResponseDto } from '@api';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ parent }) => {
|
||||
const { user } = await parent();
|
||||
|
||||
if (!user) {
|
||||
throw redirect(302, '/auth/login');
|
||||
} else if (!user.isAdmin) {
|
||||
throw redirect(302, '/photos');
|
||||
}
|
||||
|
||||
const { data: allUsers } = await serverApi.userApi.getAllUsers(false);
|
||||
return {
|
||||
user: user,
|
||||
allUsers: allUsers
|
||||
};
|
||||
};
|
||||
@@ -1,47 +1,3 @@
|
||||
<script context="module" lang="ts">
|
||||
import type { Load } from '@sveltejs/kit';
|
||||
import { api, UserResponseDto } from '@api';
|
||||
import { browser } from '$app/env';
|
||||
|
||||
export const load: Load = async ({ fetch, session }) => {
|
||||
if (!browser && !session.user) {
|
||||
return {
|
||||
status: 302,
|
||||
redirect: '/auth/login'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const user: UserResponseDto = await fetch('/data/user/get-my-user-info').then((r) =>
|
||||
r.json()
|
||||
);
|
||||
const allUsers: UserResponseDto[] = await fetch('/data/user/get-all-users?isAll=false').then(
|
||||
(r) => r.json()
|
||||
);
|
||||
|
||||
if (!user.isAdmin) {
|
||||
return {
|
||||
status: 302,
|
||||
redirect: '/photos'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
props: {
|
||||
user: user,
|
||||
allUsers: allUsers
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
status: 302,
|
||||
redirect: '/auth/login'
|
||||
};
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
@@ -54,11 +10,12 @@
|
||||
import CreateUserForm from '$lib/components/forms/create-user-form.svelte';
|
||||
import EditUserForm from '$lib/components/forms/edit-user-form.svelte';
|
||||
import StatusBox from '$lib/components/shared-components/status-box.svelte';
|
||||
import type { PageData } from './$types';
|
||||
import { api, UserResponseDto } from '@api';
|
||||
|
||||
let selectedAction: AdminSideBarSelection = AdminSideBarSelection.USER_MANAGEMENT;
|
||||
|
||||
export let user: UserResponseDto;
|
||||
export let allUsers: UserResponseDto[];
|
||||
export let data: PageData;
|
||||
|
||||
let editUser: UserResponseDto;
|
||||
|
||||
@@ -75,8 +32,8 @@
|
||||
});
|
||||
|
||||
const onUserCreated = async () => {
|
||||
const { data } = await api.userApi.getAllUsers(false);
|
||||
allUsers = data;
|
||||
const getAllUsersRes = await api.userApi.getAllUsers(false);
|
||||
data.allUsers = getAllUsersRes.data;
|
||||
shouldShowCreateUserForm = false;
|
||||
};
|
||||
|
||||
@@ -87,14 +44,14 @@
|
||||
};
|
||||
|
||||
const onEditUserSuccess = async () => {
|
||||
const { data } = await api.userApi.getAllUsers(false);
|
||||
allUsers = data;
|
||||
const getAllUsersRes = await api.userApi.getAllUsers(false);
|
||||
data.allUsers = getAllUsersRes.data;
|
||||
shouldShowEditUserForm = false;
|
||||
};
|
||||
|
||||
const onEditPasswordSuccess = async () => {
|
||||
const { data } = await api.userApi.getAllUsers(false);
|
||||
allUsers = data;
|
||||
const getAllUsersRes = await api.userApi.getAllUsers(false);
|
||||
data.allUsers = getAllUsersRes.data;
|
||||
shouldShowEditUserForm = false;
|
||||
shouldShowInfoPanel = true;
|
||||
};
|
||||
@@ -104,7 +61,7 @@
|
||||
<title>Administration - Immich</title>
|
||||
</svelte:head>
|
||||
|
||||
<NavigationBar {user} />
|
||||
<NavigationBar user={data.user} />
|
||||
|
||||
{#if shouldShowCreateUserForm}
|
||||
<FullScreenModal on:clickOutside={() => (shouldShowCreateUserForm = false)}>
|
||||
@@ -125,7 +82,7 @@
|
||||
{#if shouldShowInfoPanel}
|
||||
<FullScreenModal on:clickOutside={() => (shouldShowInfoPanel = false)}>
|
||||
<div class="border bg-white shadow-sm w-[500px] rounded-3xl p-8 text-sm">
|
||||
<h1 class="font-bold text-immich-primary text-lg mb-4">Password reset success</h1>
|
||||
<h1 class="font-medium text-immich-primary text-lg mb-4">Password reset success</h1>
|
||||
|
||||
<p>
|
||||
The user's password has been reset to the default <code
|
||||
@@ -170,7 +127,7 @@
|
||||
<section class="w-[800px] pt-4">
|
||||
{#if selectedAction === AdminSideBarSelection.USER_MANAGEMENT}
|
||||
<UserManagement
|
||||
{allUsers}
|
||||
allUsers={data.allUsers}
|
||||
on:create-user={() => (shouldShowCreateUserForm = true)}
|
||||
on:edit-user={editUserHandler}
|
||||
/>
|
||||
22
web/src/routes/albums/+page.server.ts
Normal file
22
web/src/routes/albums/+page.server.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { AlbumResponseDto, serverApi } from '@api';
|
||||
|
||||
export const load: PageServerLoad = async ({ parent }) => {
|
||||
try {
|
||||
const { user } = await parent();
|
||||
|
||||
if (!user) {
|
||||
throw Error('User is not logged in');
|
||||
}
|
||||
|
||||
const { data: albums } = await serverApi.albumApi.getAllAlbums();
|
||||
|
||||
return {
|
||||
user: user,
|
||||
albums: albums
|
||||
};
|
||||
} catch (e) {
|
||||
throw redirect(302, '/auth/login');
|
||||
}
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user