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:
|
build:
|
||||||
context: ../server
|
context: ../server
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
target: builder
|
||||||
command: npm run start:dev immich
|
command: npm run start:dev immich
|
||||||
volumes:
|
volumes:
|
||||||
- ../server:/usr/src/app
|
- ../server:/usr/src/app
|
||||||
@@ -24,6 +25,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ../machine-learning
|
context: ../machine-learning
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
target: builder
|
||||||
command: npm run start:dev
|
command: npm run start:dev
|
||||||
volumes:
|
volumes:
|
||||||
- ../machine-learning:/usr/src/app
|
- ../machine-learning:/usr/src/app
|
||||||
@@ -41,6 +43,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ../server
|
context: ../server
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
target: builder
|
||||||
command: npm run start:dev microservices
|
command: npm run start:dev microservices
|
||||||
volumes:
|
volumes:
|
||||||
- ../server:/usr/src/app
|
- ../server:/usr/src/app
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ../server
|
context: ../server
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
target: builder
|
||||||
command: npm run test:e2e
|
command: npm run test:e2e
|
||||||
expose:
|
expose:
|
||||||
- "3000"
|
- "3000"
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ upload:
|
|||||||
locale_code: de-DE
|
locale_code: de-DE
|
||||||
- file: mobile/assets/i18n/fr-FR.json
|
- file: mobile/assets/i18n/fr-FR.json
|
||||||
locale_code: fr-FR
|
locale_code: fr-FR
|
||||||
|
- file: mobile/assets/i18n/nl-NL.json
|
||||||
|
locale_code: nl-NL
|
||||||
download:
|
download:
|
||||||
files:
|
files:
|
||||||
- file: mobile/assets/i18n/en-US.json
|
- file: mobile/assets/i18n/en-US.json
|
||||||
@@ -17,3 +19,5 @@ download:
|
|||||||
locale_code: de-DE
|
locale_code: de-DE
|
||||||
- file: mobile/assets/i18n/fr-FR.json
|
- file: mobile/assets/i18n/fr-FR.json
|
||||||
locale_code: fr-FR
|
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
|
ARG DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
@@ -15,3 +16,27 @@ RUN npm rebuild @tensorflow/tfjs-node --build-from-source
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN npm run build
|
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 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
|
key.properties
|
||||||
**/*.keystore
|
**/*.keystore
|
||||||
**/*.jks
|
**/*.jks
|
||||||
|
|
||||||
|
# Fastlane
|
||||||
|
/fastlane/report.xml
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
|||||||
immediate = true,
|
immediate = true,
|
||||||
requireUnmeteredNetwork = inputData.getBoolean(DATA_KEY_UNMETERED, true),
|
requireUnmeteredNetwork = inputData.getBoolean(DATA_KEY_UNMETERED, true),
|
||||||
requireCharging = inputData.getBoolean(DATA_KEY_CHARGING, false),
|
requireCharging = inputData.getBoolean(DATA_KEY_CHARGING, false),
|
||||||
|
initialDelayInMs = ONE_MINUTE,
|
||||||
retries = inputData.getInt(DATA_KEY_RETRIES, 0) + 1)
|
retries = inputData.getInt(DATA_KEY_RETRIES, 0) + 1)
|
||||||
}
|
}
|
||||||
engine?.destroy()
|
engine?.destroy()
|
||||||
@@ -169,6 +170,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
|||||||
immediate = true,
|
immediate = true,
|
||||||
requireUnmeteredNetwork = inputData.getBoolean(DATA_KEY_UNMETERED, true),
|
requireUnmeteredNetwork = inputData.getBoolean(DATA_KEY_UNMETERED, true),
|
||||||
requireCharging = inputData.getBoolean(DATA_KEY_CHARGING, false),
|
requireCharging = inputData.getBoolean(DATA_KEY_CHARGING, false),
|
||||||
|
initialDelayInMs = ONE_MINUTE,
|
||||||
retries = inputData.getInt(DATA_KEY_RETRIES, 0) + 1)
|
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 args = call.arguments<ArrayList<*>>()!!
|
||||||
val title = args.get(0) as String
|
val title = args.get(0) as String
|
||||||
val content = args.get(1) 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()
|
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)
|
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ERROR_ID)
|
||||||
.setContentTitle(title)
|
.setContentTitle(title)
|
||||||
.setTicker(title)
|
.setTicker(title)
|
||||||
.setContentText(content)
|
.setContentText(content)
|
||||||
.setSmallIcon(R.mipmap.ic_launcher)
|
.setSmallIcon(R.mipmap.ic_launcher)
|
||||||
.setAutoCancel(true)
|
.setOnlyAlertOnce(true)
|
||||||
.build()
|
.build()
|
||||||
val notificationId = SystemClock.uptimeMillis() as Int
|
notificationManager.notify(individualTag, NOTIFICATION_ERROR_ID, notification)
|
||||||
notificationManager.notify(notificationId, notification)
|
}
|
||||||
|
|
||||||
|
private fun clearErrorNotifications() {
|
||||||
|
notificationManager.cancel(NOTIFICATION_ERROR_ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createForegroundInfo(title: String = NOTIFICATION_DEFAULT_TITLE, content: String? = null): ForegroundInfo {
|
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)
|
.setSmallIcon(R.mipmap.ic_launcher)
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.build()
|
.build()
|
||||||
return ForegroundInfo(1, notification)
|
return ForegroundInfo(NOTIFICATION_ID, notification)
|
||||||
}
|
}
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.O)
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
private fun createChannel() {
|
private fun createChannel() {
|
||||||
val foreground = NotificationChannel(NOTIFICATION_CHANNEL_ID, NOTIFICATION_CHANNEL_ID, NotificationManager.IMPORTANCE_LOW)
|
val foreground = NotificationChannel(NOTIFICATION_CHANNEL_ID, NOTIFICATION_CHANNEL_ID, NotificationManager.IMPORTANCE_LOW)
|
||||||
notificationManager.createNotificationChannel(foreground)
|
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)
|
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_ID = "immich/backgroundService"
|
||||||
private const val NOTIFICATION_CHANNEL_ERROR_ID = "immich/backgroundServiceError"
|
private const val NOTIFICATION_CHANNEL_ERROR_ID = "immich/backgroundServiceError"
|
||||||
private const val NOTIFICATION_DEFAULT_TITLE = "Immich"
|
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.
|
* 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,
|
keepExisting: Boolean = false,
|
||||||
requireUnmeteredNetwork: Boolean = false,
|
requireUnmeteredNetwork: Boolean = false,
|
||||||
requireCharging: Boolean = false,
|
requireCharging: Boolean = false,
|
||||||
|
initialDelayInMs: Long = 0,
|
||||||
retries: Int = 0) {
|
retries: Int = 0) {
|
||||||
if (!isEnabled(context)) {
|
if (!isEnabled(context)) {
|
||||||
return
|
return
|
||||||
@@ -287,9 +298,10 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
|||||||
val photoCheck = OneTimeWorkRequest.Builder(BackupWorker::class.java)
|
val photoCheck = OneTimeWorkRequest.Builder(BackupWorker::class.java)
|
||||||
.setConstraints(constraints.build())
|
.setConstraints(constraints.build())
|
||||||
.setInputData(inputData)
|
.setInputData(inputData)
|
||||||
|
.setInitialDelay(initialDelayInMs, TimeUnit.MILLISECONDS)
|
||||||
.setBackoffCriteria(
|
.setBackoffCriteria(
|
||||||
BackoffPolicy.EXPONENTIAL,
|
BackoffPolicy.EXPONENTIAL,
|
||||||
OneTimeWorkRequest.MIN_BACKOFF_MILLIS,
|
ONE_MINUTE,
|
||||||
TimeUnit.MILLISECONDS)
|
TimeUnit.MILLISECONDS)
|
||||||
.build()
|
.build()
|
||||||
val policy = if (immediate) ExistingWorkPolicy.REPLACE else (if (keepExisting) ExistingWorkPolicy.KEEP else ExistingWorkPolicy.APPEND_OR_REPLACE)
|
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',
|
task: 'bundle',
|
||||||
build_type: 'Release',
|
build_type: 'Release',
|
||||||
properties: {
|
properties: {
|
||||||
"android.injected.version.code" => 34,
|
"android.injected.version.code" => 36,
|
||||||
"android.injected.version.name" => "1.24.0",
|
"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')
|
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>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="55.750133">
|
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="65.786484">
|
||||||
|
|
||||||
</testcase>
|
</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>
|
</testcase>
|
||||||
|
|
||||||
|
|||||||
@@ -122,5 +122,5 @@
|
|||||||
"theme_setting_image_viewer_quality_title": "Qualität des Bildbetrachters",
|
"theme_setting_image_viewer_quality_title": "Qualität des Bildbetrachters",
|
||||||
"theme_setting_image_viewer_quality_subtitle": "Einstellen der Qualität des Detailbildbetrachters",
|
"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_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_upload_failure_notification": "Failed to upload {}",
|
||||||
"backup_background_service_in_progress_notification": "Backing up your assets…",
|
"backup_background_service_in_progress_notification": "Backing up your assets…",
|
||||||
"backup_background_service_current_upload_notification": "Uploading {}",
|
"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_albums": "Backup Albums",
|
||||||
"backup_controller_page_backup": "Backup",
|
"backup_controller_page_backup": "Backup",
|
||||||
"backup_controller_page_backup_selected": "Selected: ",
|
"backup_controller_page_backup_selected": "Selected: ",
|
||||||
@@ -135,5 +138,16 @@
|
|||||||
"theme_setting_image_viewer_quality_title": "Image viewer quality",
|
"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_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_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_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 40;
|
CURRENT_PROJECT_VERSION = 51;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@@ -495,7 +495,7 @@
|
|||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 40;
|
CURRENT_PROJECT_VERSION = 51;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@@ -522,7 +522,7 @@
|
|||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 40;
|
CURRENT_PROJECT_VERSION = 51;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
|||||||
@@ -17,11 +17,11 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.21.0</string>
|
<string>1.26.0</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>40</string>
|
<string>51</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true />
|
<true />
|
||||||
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
|
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
|
||||||
@@ -92,7 +92,9 @@
|
|||||||
<string>it</string>
|
<string>it</string>
|
||||||
<string>fi</string>
|
<string>fi</string>
|
||||||
<string>ja</string>
|
<string>ja</string>
|
||||||
|
<string>nl</string>
|
||||||
<string>pl</string>
|
<string>pl</string>
|
||||||
|
<string>pt</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -19,7 +19,7 @@ platform :ios do
|
|||||||
desc "iOS Beta"
|
desc "iOS Beta"
|
||||||
lane :beta do
|
lane :beta do
|
||||||
increment_version_number(
|
increment_version_number(
|
||||||
version_number: "1.24.0"
|
version_number: "1.26.0"
|
||||||
)
|
)
|
||||||
increment_build_number(
|
increment_build_number(
|
||||||
build_number: latest_testflight_build_number + 1,
|
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>
|
||||||
|
|
||||||
|
|
||||||
<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>
|
||||||
|
|
||||||
|
|
||||||
<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>
|
||||||
|
|
||||||
|
|
||||||
<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>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="4: build_app" time="80.023705">
|
<testcase classname="fastlane.lanes" name="4: build_app" time="126.240949">
|
||||||
|
|
||||||
</testcase>
|
</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>
|
</testcase>
|
||||||
|
|
||||||
|
|||||||
@@ -19,3 +19,7 @@ const String githubReleaseInfoKey = "immichGithubReleaseInfoKey"; // Key 1
|
|||||||
|
|
||||||
// User Setting Info
|
// User Setting Info
|
||||||
const String userSettingInfoBox = "immichUserSettingInfoBox";
|
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('fr', 'FR'),
|
||||||
Locale('it', 'IT'),
|
Locale('it', 'IT'),
|
||||||
Locale('ja', 'JP'),
|
Locale('ja', 'JP'),
|
||||||
Locale('pl', 'PL')
|
Locale('nl', 'NL'),
|
||||||
|
Locale('pl', 'PL'),
|
||||||
|
Locale('pt', 'PR')
|
||||||
];
|
];
|
||||||
|
|
||||||
const String translationsPath = 'assets/i18n';
|
const String translationsPath = 'assets/i18n';
|
||||||
|
|||||||
@@ -1,16 +1,25 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:auto_route/auto_route.dart';
|
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:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hive/hive.dart';
|
import 'package:hive/hive.dart';
|
||||||
import 'package:immich_mobile/constants/hive_box.dart';
|
import 'package:immich_mobile/constants/hive_box.dart';
|
||||||
import 'package:immich_mobile/routing/router.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:openapi/api.dart';
|
||||||
import 'package:transparent_image/transparent_image.dart';
|
|
||||||
|
|
||||||
class AlbumThumbnailCard extends StatelessWidget {
|
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 AlbumResponseDto album;
|
||||||
|
final CacheService cacheService;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -29,19 +38,19 @@ class AlbumThumbnailCard extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: FadeInImage(
|
child: CachedNetworkImage(
|
||||||
|
cacheManager: cacheService.getCache(CacheType.albumThumbnail),
|
||||||
|
memCacheHeight: max(400, cardSize.toInt() * 3),
|
||||||
width: cardSize,
|
width: cardSize,
|
||||||
height: cardSize,
|
height: cardSize,
|
||||||
fit: BoxFit.cover,
|
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),
|
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(
|
Padding(
|
||||||
|
|||||||
@@ -14,11 +14,13 @@ import 'package:openapi/api.dart';
|
|||||||
class AlbumViewerThumbnail extends HookConsumerWidget {
|
class AlbumViewerThumbnail extends HookConsumerWidget {
|
||||||
final AssetResponseDto asset;
|
final AssetResponseDto asset;
|
||||||
final List<AssetResponseDto> assetList;
|
final List<AssetResponseDto> assetList;
|
||||||
|
final bool showStorageIndicator;
|
||||||
|
|
||||||
const AlbumViewerThumbnail({
|
const AlbumViewerThumbnail({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.asset,
|
required this.asset,
|
||||||
required this.assetList,
|
required this.assetList,
|
||||||
|
this.showStorageIndicator = true,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -166,7 +168,7 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
|
|||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
_buildThumbnailImage(),
|
_buildThumbnailImage(),
|
||||||
_buildAssetStoreLocationIcon(),
|
if (showStorageIndicator) _buildAssetStoreLocationIcon(),
|
||||||
if (asset.type != AssetTypeEnum.IMAGE) _buildVideoLabel(),
|
if (asset.type != AssetTypeEnum.IMAGE) _buildVideoLabel(),
|
||||||
if (isMultiSelectionEnable) _buildAssetSelectionIcon(),
|
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_appbar.dart';
|
||||||
import 'package:immich_mobile/modules/album/ui/album_viewer_editable_title.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/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/routing/router.dart';
|
||||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||||
import 'package:immich_mobile/shared/ui/immich_sliver_persistent_app_bar_delegate.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) {
|
Widget _buildImageGrid(AlbumResponseDto albumInfo) {
|
||||||
|
final appSettingService = ref.watch(appSettingsServiceProvider);
|
||||||
|
final bool showStorageIndicator =
|
||||||
|
appSettingService.getSetting(AppSettingsEnum.storageIndicator);
|
||||||
|
|
||||||
if (albumInfo.assets.isNotEmpty) {
|
if (albumInfo.assets.isNotEmpty) {
|
||||||
return SliverPadding(
|
return SliverPadding(
|
||||||
padding: const EdgeInsets.only(top: 10.0),
|
padding: const EdgeInsets.only(top: 10.0),
|
||||||
sliver: SliverGrid(
|
sliver: SliverGrid(
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
crossAxisCount: 3,
|
crossAxisCount:
|
||||||
|
appSettingService.getSetting(AppSettingsEnum.tilesPerRow),
|
||||||
crossAxisSpacing: 5.0,
|
crossAxisSpacing: 5.0,
|
||||||
mainAxisSpacing: 5,
|
mainAxisSpacing: 5,
|
||||||
),
|
),
|
||||||
@@ -200,6 +207,7 @@ class AlbumViewerPage extends HookConsumerWidget {
|
|||||||
return AlbumViewerThumbnail(
|
return AlbumViewerThumbnail(
|
||||||
asset: albumInfo.assets[index],
|
asset: albumInfo.assets[index],
|
||||||
assetList: albumInfo.assets,
|
assetList: albumInfo.assets,
|
||||||
|
showStorageIndicator: showStorageIndicator,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
childCount: albumInfo.assetCount,
|
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/providers/album.provider.dart';
|
||||||
import 'package:immich_mobile/modules/album/ui/album_thumbnail_card.dart';
|
import 'package:immich_mobile/modules/album/ui/album_thumbnail_card.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
import 'package:immich_mobile/shared/services/cache.service.dart';
|
||||||
|
|
||||||
class LibraryPage extends HookConsumerWidget {
|
class LibraryPage extends HookConsumerWidget {
|
||||||
const LibraryPage({Key? key}) : super(key: key);
|
const LibraryPage({Key? key}) : super(key: key);
|
||||||
@@ -13,6 +14,7 @@ class LibraryPage extends HookConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final albums = ref.watch(albumProvider);
|
final albums = ref.watch(albumProvider);
|
||||||
|
final cacheService = ref.watch(cacheServiceProvider);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() {
|
() {
|
||||||
@@ -102,6 +104,7 @@ class LibraryPage extends HookConsumerWidget {
|
|||||||
_buildCreateAlbumButton(),
|
_buildCreateAlbumButton(),
|
||||||
for (var album in albums)
|
for (var album in albums)
|
||||||
AlbumThumbnailCard(
|
AlbumThumbnailCard(
|
||||||
|
cacheService: cacheService,
|
||||||
album: album,
|
album: album,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:auto_route/auto_route.dart';
|
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:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.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/providers/shared_album.provider.dart';
|
||||||
import 'package:immich_mobile/modules/album/ui/sharing_sliver_appbar.dart';
|
import 'package:immich_mobile/modules/album/ui/sharing_sliver_appbar.dart';
|
||||||
import 'package:immich_mobile/routing/router.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:openapi/api.dart';
|
||||||
import 'package:transparent_image/transparent_image.dart';
|
|
||||||
|
|
||||||
class SharingPage extends HookConsumerWidget {
|
class SharingPage extends HookConsumerWidget {
|
||||||
const SharingPage({Key? key}) : super(key: key);
|
const SharingPage({Key? key}) : super(key: key);
|
||||||
@@ -19,6 +21,7 @@ class SharingPage extends HookConsumerWidget {
|
|||||||
var box = Hive.box(userInfoBox);
|
var box = Hive.box(userInfoBox);
|
||||||
var thumbnailRequestUrl = '${box.get(serverEndpointKey)}/asset/thumbnail';
|
var thumbnailRequestUrl = '${box.get(serverEndpointKey)}/asset/thumbnail';
|
||||||
final List<AlbumResponseDto> sharedAlbums = ref.watch(sharedAlbumProvider);
|
final List<AlbumResponseDto> sharedAlbums = ref.watch(sharedAlbumProvider);
|
||||||
|
final CacheService cacheService = ref.watch(cacheServiceProvider);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() {
|
() {
|
||||||
@@ -32,29 +35,26 @@ class SharingPage extends HookConsumerWidget {
|
|||||||
return SliverList(
|
return SliverList(
|
||||||
delegate: SliverChildBuilderDelegate(
|
delegate: SliverChildBuilderDelegate(
|
||||||
(BuildContext context, int index) {
|
(BuildContext context, int index) {
|
||||||
String thumbnailUrl = sharedAlbums[index].albumThumbnailAssetId !=
|
final album = sharedAlbums[index];
|
||||||
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";
|
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
contentPadding:
|
contentPadding:
|
||||||
const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
|
const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
|
||||||
leading: ClipRRect(
|
leading: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: FadeInImage(
|
child: CachedNetworkImage(
|
||||||
width: 60,
|
width: 60,
|
||||||
height: 60,
|
height: 60,
|
||||||
|
memCacheHeight: 200,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
placeholder: MemoryImage(kTransparentImage),
|
cacheManager:
|
||||||
image: NetworkImage(
|
cacheService.getCache(CacheType.sharedAlbumThumbnail),
|
||||||
thumbnailUrl,
|
imageUrl: getAlbumThumbnailUrl(album),
|
||||||
headers: {
|
cacheKey: album.albumThumbnailAssetId,
|
||||||
"Authorization": "Bearer ${box.get(accessTokenKey)}"
|
httpHeaders: {
|
||||||
},
|
"Authorization": "Bearer ${box.get(accessTokenKey)}"
|
||||||
),
|
},
|
||||||
fadeInDuration: const Duration(milliseconds: 200),
|
fadeInDuration: const Duration(milliseconds: 200),
|
||||||
fadeOutDuration: const Duration(milliseconds: 200),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'dart:io';
|
|||||||
import 'dart:isolate';
|
import 'dart:isolate';
|
||||||
import 'dart:ui' show IsolateNameServer, PluginUtilities;
|
import 'dart:ui' show IsolateNameServer, PluginUtilities;
|
||||||
import 'package:cancellation_token_http/http.dart';
|
import 'package:cancellation_token_http/http.dart';
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter/widgets.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/models/hive_backup_albums.model.dart';
|
||||||
import 'package:immich_mobile/modules/backup/services/backup.service.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/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:immich_mobile/shared/services/api.service.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
|
|
||||||
@@ -39,6 +41,7 @@ class BackgroundService {
|
|||||||
bool _hasLock = false;
|
bool _hasLock = false;
|
||||||
SendPort? _waitingIsolate;
|
SendPort? _waitingIsolate;
|
||||||
ReceivePort? _rp;
|
ReceivePort? _rp;
|
||||||
|
bool _errorGracePeriodExceeded = true;
|
||||||
|
|
||||||
bool get isForegroundInitialized {
|
bool get isForegroundInitialized {
|
||||||
return _isForegroundInitialized;
|
return _isForegroundInitialized;
|
||||||
@@ -140,8 +143,8 @@ class BackgroundService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Updates the notification shown by the background service
|
/// Updates the notification shown by the background service
|
||||||
Future<bool> updateNotification({
|
Future<bool> _updateNotification({
|
||||||
String title = "Immich",
|
required String title,
|
||||||
String? content,
|
String? content,
|
||||||
}) async {
|
}) async {
|
||||||
if (!Platform.isAndroid) {
|
if (!Platform.isAndroid) {
|
||||||
@@ -153,28 +156,44 @@ class BackgroundService {
|
|||||||
.invokeMethod('updateNotification', [title, content]);
|
.invokeMethod('updateNotification', [title, content]);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debugPrint("[updateNotification] failed to communicate with plugin");
|
debugPrint("[_updateNotification] failed to communicate with plugin");
|
||||||
}
|
}
|
||||||
return Future.value(false);
|
return Future.value(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shows a new priority notification
|
/// Shows a new priority notification
|
||||||
Future<bool> showErrorNotification(
|
Future<bool> _showErrorNotification({
|
||||||
String title,
|
required String title,
|
||||||
String content,
|
String? content,
|
||||||
) async {
|
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) {
|
if (!Platform.isAndroid) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (_isBackgroundInitialized) {
|
if (_isBackgroundInitialized) {
|
||||||
return await _backgroundChannel
|
return await _backgroundChannel.invokeMethod('clearErrorNotifications');
|
||||||
.invokeMethod('showError', [title, content]);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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
|
/// await to ensure this thread (foreground or background) has exclusive access
|
||||||
@@ -278,7 +297,15 @@ class BackgroundService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
await translationsLoaded;
|
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) {
|
} catch (error) {
|
||||||
debugPrint(error.toString());
|
debugPrint(error.toString());
|
||||||
return false;
|
return false;
|
||||||
@@ -303,6 +330,8 @@ class BackgroundService {
|
|||||||
Hive.registerAdapter(HiveBackupAlbumsAdapter());
|
Hive.registerAdapter(HiveBackupAlbumsAdapter());
|
||||||
await Hive.openBox(userInfoBox);
|
await Hive.openBox(userInfoBox);
|
||||||
await Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox);
|
await Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox);
|
||||||
|
await Hive.openBox(userSettingInfoBox);
|
||||||
|
await Hive.openBox(backgroundBackupInfoBox);
|
||||||
|
|
||||||
ApiService apiService = ApiService();
|
ApiService apiService = ApiService();
|
||||||
apiService.setEndpoint(Hive.box(userInfoBox).get(serverEndpointKey));
|
apiService.setEndpoint(Hive.box(userInfoBox).get(serverEndpointKey));
|
||||||
@@ -313,23 +342,36 @@ class BackgroundService {
|
|||||||
await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
|
await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
|
||||||
final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey);
|
final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey);
|
||||||
if (backupAlbumInfo == null) {
|
if (backupAlbumInfo == null) {
|
||||||
|
_clearErrorNotifications();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
await PhotoManager.setIgnorePermissionCheck(true);
|
await PhotoManager.setIgnorePermissionCheck(true);
|
||||||
|
_errorGracePeriodExceeded = _isErrorGracePeriodExceeded();
|
||||||
|
|
||||||
if (_canceledBySystem) {
|
if (_canceledBySystem) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
final List<AssetEntity> toUpload =
|
List<AssetEntity> toUpload =
|
||||||
await backupService.getAssetsToBackup(backupAlbumInfo);
|
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) {
|
if (_canceledBySystem) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (toUpload.isEmpty) {
|
if (toUpload.isEmpty) {
|
||||||
|
_clearErrorNotifications();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,10 +385,16 @@ class BackgroundService {
|
|||||||
_onBackupError,
|
_onBackupError,
|
||||||
);
|
);
|
||||||
if (ok) {
|
if (ok) {
|
||||||
|
_clearErrorNotifications();
|
||||||
await box.put(
|
await box.put(
|
||||||
backupInfoKey,
|
backupInfoKey,
|
||||||
backupAlbumInfo,
|
backupAlbumInfo,
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
_showErrorNotification(
|
||||||
|
title: "backup_background_service_error_title".tr(),
|
||||||
|
content: "backup_background_service_backup_failed_message".tr(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
@@ -358,20 +406,48 @@ class BackgroundService {
|
|||||||
void _onProgress(int sent, int total) {}
|
void _onProgress(int sent, int total) {}
|
||||||
|
|
||||||
void _onBackupError(ErrorUploadAsset errorAssetInfo) {
|
void _onBackupError(ErrorUploadAsset errorAssetInfo) {
|
||||||
showErrorNotification(
|
_showErrorNotification(
|
||||||
"backup_background_service_upload_failure_notification"
|
title: "Upload failed",
|
||||||
|
content: "backup_background_service_upload_failure_notification"
|
||||||
.tr(args: [errorAssetInfo.fileName]),
|
.tr(args: [errorAssetInfo.fileName]),
|
||||||
errorAssetInfo.errorMessage,
|
individualTag: errorAssetInfo.id,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset) {
|
void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset) {
|
||||||
updateNotification(
|
_updateNotification(
|
||||||
title: "backup_background_service_in_progress_notification".tr(),
|
title: "backup_background_service_in_progress_notification".tr(),
|
||||||
content: "backup_background_service_current_upload_notification"
|
content: "backup_background_service_current_upload_notification"
|
||||||
.tr(args: [currentUploadAsset.fileName]),
|
.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
|
/// 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
|
/// Returns all assets newer than the last successful backup per album
|
||||||
/// time of the last successfull backup per album
|
Future<List<AssetEntity>> buildUploadCandidates(
|
||||||
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(
|
|
||||||
HiveBackupAlbums backupAlbums,
|
HiveBackupAlbums backupAlbums,
|
||||||
) async {
|
) async {
|
||||||
final filter = FilterOptionGroup(
|
final filter = FilterOptionGroup(
|
||||||
@@ -147,7 +134,8 @@ class BackupService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<AssetEntity>> _removeAlreadyUploadedAssets(
|
/// Returns a new list of assets not yet uploaded
|
||||||
|
Future<List<AssetEntity>> removeAlreadyUploadedAssets(
|
||||||
List<AssetEntity> candidates,
|
List<AssetEntity> candidates,
|
||||||
) async {
|
) async {
|
||||||
final String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
|
final String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
|
||||||
|
|||||||
@@ -7,11 +7,15 @@ import 'package:openapi/api.dart';
|
|||||||
class ImageGrid extends ConsumerWidget {
|
class ImageGrid extends ConsumerWidget {
|
||||||
final List<AssetResponseDto> assetGroup;
|
final List<AssetResponseDto> assetGroup;
|
||||||
final List<AssetResponseDto> sortedAssetGroup;
|
final List<AssetResponseDto> sortedAssetGroup;
|
||||||
|
final int tilesPerRow;
|
||||||
|
final bool showStorageIndicator;
|
||||||
|
|
||||||
ImageGrid({
|
ImageGrid({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.assetGroup,
|
required this.assetGroup,
|
||||||
required this.sortedAssetGroup,
|
required this.sortedAssetGroup,
|
||||||
|
this.tilesPerRow = 4,
|
||||||
|
this.showStorageIndicator = true,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
List<AssetResponseDto> imageSortedList = [];
|
List<AssetResponseDto> imageSortedList = [];
|
||||||
@@ -19,8 +23,8 @@ class ImageGrid extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
return SliverGrid(
|
return SliverGrid(
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
crossAxisCount: 4,
|
crossAxisCount: tilesPerRow,
|
||||||
crossAxisSpacing: 5.0,
|
crossAxisSpacing: 5.0,
|
||||||
mainAxisSpacing: 5,
|
mainAxisSpacing: 5,
|
||||||
),
|
),
|
||||||
@@ -34,6 +38,7 @@ class ImageGrid extends ConsumerWidget {
|
|||||||
ThumbnailImage(
|
ThumbnailImage(
|
||||||
asset: assetGroup[index],
|
asset: assetGroup[index],
|
||||||
assetList: sortedAssetGroup,
|
assetList: sortedAssetGroup,
|
||||||
|
showStorageIndicator: showStorageIndicator,
|
||||||
),
|
),
|
||||||
if (assetType != AssetTypeEnum.IMAGE)
|
if (assetType != AssetTypeEnum.IMAGE)
|
||||||
Positioned(
|
Positioned(
|
||||||
|
|||||||
@@ -15,8 +15,13 @@ import 'package:openapi/api.dart';
|
|||||||
class ThumbnailImage extends HookConsumerWidget {
|
class ThumbnailImage extends HookConsumerWidget {
|
||||||
final AssetResponseDto asset;
|
final AssetResponseDto asset;
|
||||||
final List<AssetResponseDto> assetList;
|
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);
|
: super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -123,7 +128,7 @@ class ThumbnailImage extends HookConsumerWidget {
|
|||||||
child: _buildSelectionIcon(asset),
|
child: _buildSelectionIcon(asset),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Positioned(
|
if (showStorageIndicator) Positioned(
|
||||||
right: 10,
|
right: 10,
|
||||||
bottom: 5,
|
bottom: 5,
|
||||||
child: Icon(
|
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/immich_sliver_appbar.dart';
|
||||||
import 'package:immich_mobile/modules/home/ui/monthly_title_text.dart';
|
import 'package:immich_mobile/modules/home/ui/monthly_title_text.dart';
|
||||||
import 'package:immich_mobile/modules/home/ui/profile_drawer/profile_drawer.dart';
|
import 'package:immich_mobile/modules/home/ui/profile_drawer/profile_drawer.dart';
|
||||||
|
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/asset.provider.dart';
|
||||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||||
@@ -21,6 +23,8 @@ class HomePage extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final appSettingService = ref.watch(appSettingsServiceProvider);
|
||||||
|
|
||||||
ScrollController scrollController = useScrollController();
|
ScrollController scrollController = useScrollController();
|
||||||
var assetGroupByDateTime = ref.watch(assetGroupByDateTimeProvider);
|
var assetGroupByDateTime = ref.watch(assetGroupByDateTimeProvider);
|
||||||
List<Widget> imageGridGroup = [];
|
List<Widget> imageGridGroup = [];
|
||||||
@@ -61,35 +65,45 @@ class HomePage extends HookConsumerWidget {
|
|||||||
int? lastMonth;
|
int? lastMonth;
|
||||||
|
|
||||||
assetGroupByDateTime.forEach((dateGroup, immichAssetList) {
|
assetGroupByDateTime.forEach((dateGroup, immichAssetList) {
|
||||||
DateTime parseDateGroup = DateTime.parse(dateGroup);
|
try {
|
||||||
int currentMonth = parseDateGroup.month;
|
DateTime parseDateGroup = DateTime.parse(dateGroup);
|
||||||
|
int currentMonth = parseDateGroup.month;
|
||||||
|
|
||||||
if (lastMonth != null) {
|
if (lastMonth != null) {
|
||||||
if (currentMonth - lastMonth! != 0) {
|
if (currentMonth - lastMonth! != 0) {
|
||||||
imageGridGroup.add(
|
imageGridGroup.add(
|
||||||
MonthlyTitleText(
|
MonthlyTitleText(
|
||||||
isoDate: dateGroup,
|
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:hive_flutter/hive_flutter.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:immich_mobile/constants/hive_box.dart';
|
import 'package:immich_mobile/constants/hive_box.dart';
|
||||||
|
|
||||||
enum AppSettingsEnum {
|
enum AppSettingsEnum<T> {
|
||||||
threeStageLoading, // true, false,
|
threeStageLoading<bool>("threeStageLoading", false),
|
||||||
themeMode, // "light","dark","system"
|
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 {
|
class AppSettingsService {
|
||||||
@@ -15,63 +22,26 @@ class AppSettingsService {
|
|||||||
hiveBox = Hive.box(userSettingInfoBox);
|
hiveBox = Hive.box(userSettingInfoBox);
|
||||||
}
|
}
|
||||||
|
|
||||||
T getSetting<T>(AppSettingsEnum settingType) {
|
T getSetting<T>(AppSettingsEnum<T> settingType) {
|
||||||
var settingKey = _settingHiveBoxKeyLookup(settingType);
|
if (!hiveBox.containsKey(settingType.hiveKey)) {
|
||||||
|
return _setDefault(settingType);
|
||||||
if (!hiveBox.containsKey(settingKey)) {
|
|
||||||
T defaultSetting = _setDefaultSetting(settingType);
|
|
||||||
return defaultSetting;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = hiveBox.get(settingKey);
|
var result = hiveBox.get(settingType.hiveKey);
|
||||||
|
|
||||||
if (result is T) {
|
if (result is! T) {
|
||||||
return result;
|
return _setDefault(settingType);
|
||||||
} else {
|
|
||||||
debugPrint("Incorrect setting type");
|
|
||||||
throw TypeError();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSetting<T>(AppSettingsEnum settingType, T value) {
|
setSetting<T>(AppSettingsEnum<T> settingType, T value) {
|
||||||
var settingKey = _settingHiveBoxKeyLookup(settingType);
|
hiveBox.put(settingType.hiveKey, value);
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_setDefaultSetting(AppSettingsEnum settingType) {
|
T _setDefault<T>(AppSettingsEnum<T> settingType) {
|
||||||
var settingKey = _settingHiveBoxKeyLookup(settingType);
|
hiveBox.put(settingType.hiveKey, settingType.defaultValue);
|
||||||
|
return settingType.defaultValue;
|
||||||
// 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';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
return SwitchListTile.adaptive(
|
||||||
|
activeColor: Theme.of(context).primaryColor,
|
||||||
title: const Text(
|
title: const Text(
|
||||||
"theme_setting_three_stage_loading_title",
|
"theme_setting_three_stage_loading_title",
|
||||||
style: TextStyle(
|
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:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/modules/settings/ui/asset_list_settings/asset_list_settings.dart';
|
||||||
import 'package:immich_mobile/modules/settings/ui/image_viewer_quality_setting/image_viewer_quality_setting.dart';
|
import 'package:immich_mobile/modules/settings/ui/image_viewer_quality_setting/image_viewer_quality_setting.dart';
|
||||||
|
import 'package:immich_mobile/modules/settings/ui/notification_setting/notification_setting.dart';
|
||||||
import 'package:immich_mobile/modules/settings/ui/theme_setting/theme_setting.dart';
|
import 'package:immich_mobile/modules/settings/ui/theme_setting/theme_setting.dart';
|
||||||
|
|
||||||
class SettingsPage extends HookConsumerWidget {
|
class SettingsPage extends HookConsumerWidget {
|
||||||
@@ -36,6 +40,8 @@ class SettingsPage extends HookConsumerWidget {
|
|||||||
tiles: [
|
tiles: [
|
||||||
const ImageViewerQualitySetting(),
|
const ImageViewerQualitySetting(),
|
||||||
const ThemeSetting(),
|
const ThemeSetting(),
|
||||||
|
const AssetListSettings(),
|
||||||
|
if (Platform.isAndroid) const NotificationSetting(),
|
||||||
],
|
],
|
||||||
).toList(),
|
).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';
|
import '../constants/hive_box.dart';
|
||||||
|
|
||||||
String getThumbnailUrl(final AssetResponseDto asset,
|
String getThumbnailUrl(
|
||||||
{ThumbnailFormat type = ThumbnailFormat.WEBP}) {
|
final AssetResponseDto asset, {
|
||||||
final box = Hive.box(userInfoBox);
|
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) {
|
String getImageUrl(final AssetResponseDto asset) {
|
||||||
final box = Hive.box(userInfoBox);
|
final box = Hive.box(userInfoBox);
|
||||||
return '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=false';
|
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
|
source: hosted
|
||||||
version: "0.6.8"
|
version: "0.6.8"
|
||||||
flutter_cache_manager:
|
flutter_cache_manager:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: flutter_cache_manager
|
name: flutter_cache_manager
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: immich_mobile
|
|||||||
description: Immich - selfhosted backup media file on mobile phone
|
description: Immich - selfhosted backup media file on mobile phone
|
||||||
|
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 1.24.0+34
|
version: 1.26.0+35
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=2.17.0 <3.0.0"
|
sdk: ">=2.17.0 <3.0.0"
|
||||||
@@ -43,6 +43,7 @@ dependencies:
|
|||||||
easy_localization: ^3.0.1
|
easy_localization: ^3.0.1
|
||||||
share_plus: ^4.0.10
|
share_plus: ^4.0.10
|
||||||
flutter_displaymode: ^0.4.0
|
flutter_displaymode: ^0.4.0
|
||||||
|
flutter_cache_manager: 3.3.0
|
||||||
|
|
||||||
path: ^1.8.1
|
path: ^1.8.1
|
||||||
path_provider: ^2.0.11
|
path_provider: ^2.0.11
|
||||||
|
|||||||
@@ -1,15 +1,32 @@
|
|||||||
FROM node:16-alpine3.14 as core
|
# Build stage
|
||||||
|
FROM node:16-alpine3.14 as builder
|
||||||
ARG DEBIAN_FRONTEND=noninteractive
|
|
||||||
|
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
COPY package.json package-lock.json ./
|
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
|
RUN npm ci
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN npm run build
|
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
|
// TODO: No need to return boolean if using a singe delete query
|
||||||
if (deleteAssetCount == removeAssetsDto.assetIds.length) {
|
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 {
|
} else {
|
||||||
throw new BadRequestException('Some assets were not found in the album');
|
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 { Injectable } from '@nestjs/common';
|
||||||
import { ServerInfoResponseDto } from './response-dto/server-info-response.dto';
|
import { ServerInfoResponseDto } from './response-dto/server-info-response.dto';
|
||||||
import diskusage from 'diskusage';
|
import diskusage from 'diskusage';
|
||||||
import { APP_UPLOAD_LOCATION } from '../../constants/upload_location.constant';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ServerInfoService {
|
export class ServerInfoService {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { immichAppConfig } from '@app/common/config';
|
||||||
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
||||||
import { UserModule } from './api-v1/user/user.module';
|
import { UserModule } from './api-v1/user/user.module';
|
||||||
import { AssetModule } from './api-v1/asset/asset.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 { ImmichJwtModule } from './modules/immich-jwt/immich-jwt.module';
|
||||||
import { DeviceInfoModule } from './api-v1/device-info/device-info.module';
|
import { DeviceInfoModule } from './api-v1/device-info/device-info.module';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { immichAppConfig } from './config/app.config';
|
|
||||||
import { BullModule } from '@nestjs/bull';
|
import { BullModule } from '@nestjs/bull';
|
||||||
import { ServerInfoModule } from './api-v1/server-info/server-info.module';
|
import { ServerInfoModule } from './api-v1/server-info/server-info.module';
|
||||||
import { BackgroundTaskModule } from './modules/background-task/background-task.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 { HttpException, HttpStatus } from '@nestjs/common';
|
||||||
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
|
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
|
||||||
import { existsSync, mkdirSync } from 'fs';
|
import { existsSync, mkdirSync } from 'fs';
|
||||||
import { diskStorage } from 'multer';
|
import { diskStorage } from 'multer';
|
||||||
import { extname } from 'path';
|
import { extname } from 'path';
|
||||||
import { Request } from 'express';
|
import { Request } from 'express';
|
||||||
import { APP_UPLOAD_LOCATION } from '../constants/upload_location.constant';
|
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
export const assetUploadOption: MulterOptions = {
|
export const assetUploadOption: MulterOptions = {
|
||||||
fileFilter: (req: Request, file: any, cb: any) => {
|
fileFilter: (req: Request, file: any, cb: any) => {
|
||||||
if (file.mimetype.match(/\/(jpg|jpeg|png|gif|mp4|x-msvideo|quicktime|heic|heif|dng|webp)$/)) {
|
if (file.mimetype.match(/\/(jpg|jpeg|png|gif|mp4|x-msvideo|quicktime|heic|heif|dng|x-adobe-dng|webp|tiff)$/)) {
|
||||||
cb(null, true);
|
cb(null, true);
|
||||||
} else {
|
} else {
|
||||||
cb(new HttpException(`Unsupported file type ${extname(file.originalname)}`, HttpStatus.BAD_REQUEST), false);
|
cb(new HttpException(`Unsupported file type ${extname(file.originalname)}`, HttpStatus.BAD_REQUEST), false);
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
|
import { APP_UPLOAD_LOCATION } from '@app/common/constants';
|
||||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||||
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
|
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
|
||||||
import { existsSync, mkdirSync } from 'fs';
|
import { existsSync, mkdirSync } from 'fs';
|
||||||
import { diskStorage } from 'multer';
|
import { diskStorage } from 'multer';
|
||||||
import { extname } from 'path';
|
import { extname } from 'path';
|
||||||
import { Request } from 'express';
|
import { Request } from 'express';
|
||||||
import { APP_UPLOAD_LOCATION } from '../constants/upload_location.constant';
|
|
||||||
|
|
||||||
export const profileImageUploadOption: MulterOptions = {
|
export const profileImageUploadOption: MulterOptions = {
|
||||||
fileFilter: (req: Request, file: any, cb: any) => {
|
fileFilter: (req: Request, file: any, cb: any) => {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export interface IServerVersion {
|
|||||||
|
|
||||||
export const serverVersion: IServerVersion = {
|
export const serverVersion: IServerVersion = {
|
||||||
major: 1,
|
major: 1,
|
||||||
minor: 24,
|
minor: 26,
|
||||||
patch: 0,
|
patch: 0,
|
||||||
build: 0,
|
build: 0,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"^.+\\.(t|j)s$": "ts-jest"
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
},
|
},
|
||||||
"moduleNameMapper": {
|
"moduleNameMapper": {
|
||||||
|
"@app/common/(.*)": "<rootDir>../../../libs/common/src/$1",
|
||||||
"@app/database/config/(.*)": "<rootDir>../../../libs/database/src/config/$1",
|
"@app/database/config/(.*)": "<rootDir>../../../libs/database/src/config/$1",
|
||||||
"@app/database/entities/(.*)": "<rootDir>../../../libs/database/src/entities/$1"
|
"@app/database/entities/(.*)": "<rootDir>../../../libs/database/src/entities/$1"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,29 @@
|
|||||||
import { BullModule } from '@nestjs/bull';
|
import { immichAppConfig } from '@app/common/config';
|
||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
|
||||||
import { DatabaseModule } from '@app/database';
|
import { DatabaseModule } from '@app/database';
|
||||||
import { AssetEntity } from '@app/database/entities/asset.entity';
|
import { AssetEntity } from '@app/database/entities/asset.entity';
|
||||||
import { ExifEntity } from '@app/database/entities/exif.entity';
|
import { ExifEntity } from '@app/database/entities/exif.entity';
|
||||||
import { SmartInfoEntity } from '@app/database/entities/smart-info.entity';
|
import { SmartInfoEntity } from '@app/database/entities/smart-info.entity';
|
||||||
import { UserEntity } from '@app/database/entities/user.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 {
|
import {
|
||||||
assetUploadedQueueName,
|
assetUploadedQueueName,
|
||||||
metadataExtractionQueueName,
|
metadataExtractionQueueName,
|
||||||
thumbnailGeneratorQueueName,
|
thumbnailGeneratorQueueName,
|
||||||
videoConversionQueueName,
|
videoConversionQueueName,
|
||||||
} from '@app/job/constants/queue-name.constant';
|
} 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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
ConfigModule.forRoot(immichAppConfig),
|
||||||
DatabaseModule,
|
DatabaseModule,
|
||||||
TypeOrmModule.forFeature([UserEntity, ExifEntity, AssetEntity, SmartInfoEntity]),
|
TypeOrmModule.forFeature([UserEntity, ExifEntity, AssetEntity, SmartInfoEntity]),
|
||||||
BullModule.forRootAsync({
|
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 { AssetType } from '@app/database/entities/asset.entity';
|
||||||
import { randomUUID } from 'crypto';
|
|
||||||
import {
|
import {
|
||||||
IAssetUploadedJob,
|
IAssetUploadedJob,
|
||||||
IMetadataExtractionJob,
|
IMetadataExtractionJob,
|
||||||
@@ -17,6 +14,9 @@ import {
|
|||||||
mp4ConversionProcessorName,
|
mp4ConversionProcessorName,
|
||||||
videoMetadataExtractionProcessorName,
|
videoMetadataExtractionProcessorName,
|
||||||
} from '@app/job';
|
} from '@app/job';
|
||||||
|
import { InjectQueue, Process, Processor } from '@nestjs/bull';
|
||||||
|
import { Job, Queue } from 'bull';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
@Processor(assetUploadedQueueName)
|
@Processor(assetUploadedQueueName)
|
||||||
export class AssetUploadedProcessor {
|
export class AssetUploadedProcessor {
|
||||||
@@ -64,7 +64,11 @@ export class AssetUploadedProcessor {
|
|||||||
|
|
||||||
// Extract video duration if uploaded from the web & CLI
|
// Extract video duration if uploaded from the web & CLI
|
||||||
if (asset.type == AssetType.VIDEO) {
|
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 { 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 { 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 { SmartInfoEntity } from '@app/database/entities/smart-info.entity';
|
||||||
import ffmpeg from 'fluent-ffmpeg';
|
|
||||||
import path from 'path';
|
|
||||||
import {
|
import {
|
||||||
IExifExtractionProcessor,
|
IExifExtractionProcessor,
|
||||||
IVideoLengthExtractionProcessor,
|
IVideoLengthExtractionProcessor,
|
||||||
@@ -24,6 +12,18 @@ import {
|
|||||||
reverseGeocodingProcessorName,
|
reverseGeocodingProcessorName,
|
||||||
IReverseGeocodingProcessor,
|
IReverseGeocodingProcessor,
|
||||||
} from '@app/job';
|
} from '@app/job';
|
||||||
|
import { MapiResponse } from '@mapbox/mapbox-sdk/lib/classes/mapi-response';
|
||||||
|
import mapboxGeocoding, { GeocodeService } from '@mapbox/mapbox-sdk/services/geocoding';
|
||||||
|
import { Process, Processor } from '@nestjs/bull';
|
||||||
|
import { 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)
|
@Processor(metadataExtractionQueueName)
|
||||||
export class MetadataExtractionProcessor {
|
export class MetadataExtractionProcessor {
|
||||||
@@ -60,8 +60,8 @@ export class MetadataExtractionProcessor {
|
|||||||
newExif.make = exifData['Make'] || null;
|
newExif.make = exifData['Make'] || null;
|
||||||
newExif.model = exifData['Model'] || null;
|
newExif.model = exifData['Model'] || null;
|
||||||
newExif.imageName = path.parse(fileName).name || null;
|
newExif.imageName = path.parse(fileName).name || null;
|
||||||
newExif.exifImageHeight = exifData['ExifImageHeight'] || null;
|
newExif.exifImageHeight = exifData['ExifImageHeight'] || exifData['ImageHeight'] || null;
|
||||||
newExif.exifImageWidth = exifData['ExifImageWidth'] || null;
|
newExif.exifImageWidth = exifData['ExifImageWidth'] || exifData['ImageWidth'] || null;
|
||||||
newExif.fileSizeInByte = fileSize || null;
|
newExif.fileSizeInByte = fileSize || null;
|
||||||
newExif.orientation = exifData['Orientation'] || null;
|
newExif.orientation = exifData['Orientation'] || null;
|
||||||
newExif.dateTimeOriginal = exifData['DateTimeOriginal'] || null;
|
newExif.dateTimeOriginal = exifData['DateTimeOriginal'] || null;
|
||||||
@@ -85,9 +85,21 @@ export class MetadataExtractionProcessor {
|
|||||||
|
|
||||||
const res: [] = geoCodeInfo.body['features'];
|
const res: [] = geoCodeInfo.body['features'];
|
||||||
|
|
||||||
const city = res.filter((geoInfo) => geoInfo['place_type'][0] == 'place')[0]['text'];
|
let city = '';
|
||||||
const state = res.filter((geoInfo) => geoInfo['place_type'][0] == 'region')[0]['text'];
|
let state = '';
|
||||||
const country = res.filter((geoInfo) => geoInfo['place_type'][0] == 'country')[0]['text'];
|
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.city = city || null;
|
||||||
newExif.state = state || null;
|
newExif.state = state || null;
|
||||||
@@ -114,9 +126,21 @@ export class MetadataExtractionProcessor {
|
|||||||
|
|
||||||
const res: [] = geoCodeInfo.body['features'];
|
const res: [] = geoCodeInfo.body['features'];
|
||||||
|
|
||||||
const city = res.filter((geoInfo) => geoInfo['place_type'][0] == 'place')[0]['text'];
|
let city = '';
|
||||||
const state = res.filter((geoInfo) => geoInfo['place_type'][0] == 'region')[0]['text'];
|
let state = '';
|
||||||
const country = res.filter((geoInfo) => geoInfo['place_type'][0] == 'country')[0]['text'];
|
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 });
|
await this.exifRepository.update({ id: exif.id }, { city, state, country });
|
||||||
}
|
}
|
||||||
@@ -166,33 +190,130 @@ export class MetadataExtractionProcessor {
|
|||||||
|
|
||||||
@Process({ name: videoMetadataExtractionProcessorName, concurrency: 2 })
|
@Process({ name: videoMetadataExtractionProcessorName, concurrency: 2 })
|
||||||
async extractVideoMetadata(job: Job<IVideoLengthExtractionProcessor>) {
|
async extractVideoMetadata(job: Job<IVideoLengthExtractionProcessor>) {
|
||||||
const { asset } = job.data;
|
const { asset, fileName } = job.data;
|
||||||
|
|
||||||
ffmpeg.ffprobe(asset.originalPath, async (err, data) => {
|
try {
|
||||||
if (!err) {
|
const data = await new Promise<ffmpeg.FfprobeData>((resolve, reject) =>
|
||||||
let durationString = asset.duration;
|
ffmpeg.ffprobe(asset.originalPath, (err, data) => {
|
||||||
let createdAt = asset.createdAt;
|
if (err) return reject(err);
|
||||||
|
return resolve(data);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let durationString = asset.duration;
|
||||||
|
let createdAt = asset.createdAt;
|
||||||
|
|
||||||
if (data.format.duration) {
|
if (data.format.duration) {
|
||||||
durationString = this.extractDuration(data.format.duration);
|
durationString = this.extractDuration(data.format.duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
const videoTags = data.format.tags;
|
const videoTags = data.format.tags;
|
||||||
if (videoTags) {
|
if (videoTags) {
|
||||||
if (videoTags['com.apple.quicktime.creationdate']) {
|
if (videoTags['com.apple.quicktime.creationdate']) {
|
||||||
createdAt = String(videoTags['com.apple.quicktime.creationdate']);
|
createdAt = String(videoTags['com.apple.quicktime.creationdate']);
|
||||||
} else if (videoTags['creation_time']) {
|
} else if (videoTags['creation_time']) {
|
||||||
createdAt = String(videoTags['creation_time']);
|
createdAt = String(videoTags['creation_time']);
|
||||||
} else {
|
|
||||||
createdAt = asset.createdAt;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
createdAt = asset.createdAt;
|
createdAt = asset.createdAt;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
await this.assetRepository.update({ id: asset.id }, { duration: durationString, createdAt: createdAt });
|
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) {
|
private extractDuration(duration: number) {
|
||||||
@@ -202,8 +323,6 @@ export class MetadataExtractionProcessor {
|
|||||||
const minutes = Math.floor((videoDurationInSecond - hours * 3600) / 60);
|
const minutes = Math.floor((videoDurationInSecond - hours * 3600) / 60);
|
||||||
const seconds = videoDurationInSecond - hours * 3600 - minutes * 60;
|
const seconds = videoDurationInSecond - hours * 3600 - minutes * 60;
|
||||||
|
|
||||||
return `${hours}:${minutes < 10 ? '0' + minutes.toString() : minutes}:${
|
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.000000`;
|
||||||
seconds < 10 ? '0' + seconds.toString() : seconds
|
|
||||||
}.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 { 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 {
|
import {
|
||||||
WebpGeneratorProcessor,
|
WebpGeneratorProcessor,
|
||||||
generateJPEGThumbnailProcessorName,
|
generateJPEGThumbnailProcessorName,
|
||||||
@@ -19,7 +9,17 @@ import {
|
|||||||
thumbnailGeneratorQueueName,
|
thumbnailGeneratorQueueName,
|
||||||
JpegGeneratorProcessor,
|
JpegGeneratorProcessor,
|
||||||
} from '@app/job';
|
} 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 { 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)
|
@Processor(thumbnailGeneratorQueueName)
|
||||||
export class ThumbnailGeneratorProcessor {
|
export class ThumbnailGeneratorProcessor {
|
||||||
@@ -51,62 +51,47 @@ export class ThumbnailGeneratorProcessor {
|
|||||||
const jpegThumbnailPath = resizePath + originalFilename + '.jpeg';
|
const jpegThumbnailPath = resizePath + originalFilename + '.jpeg';
|
||||||
|
|
||||||
if (asset.type == AssetType.IMAGE) {
|
if (asset.type == AssetType.IMAGE) {
|
||||||
sharp(asset.originalPath)
|
await sharp(asset.originalPath).resize(1440, 2560, { fit: 'inside' }).jpeg().rotate().toFile(jpegThumbnailPath);
|
||||||
.resize(1440, 2560, { fit: 'inside' })
|
await this.assetRepository.update({ id: asset.id }, { resizePath: jpegThumbnailPath });
|
||||||
.jpeg()
|
|
||||||
.rotate()
|
|
||||||
.toFile(jpegThumbnailPath, async (err) => {
|
|
||||||
if (!err) {
|
|
||||||
await this.assetRepository.update({ id: asset.id }, { resizePath: jpegThumbnailPath });
|
|
||||||
|
|
||||||
// Update resize path to send to generate webp queue
|
// Update resize path to send to generate webp queue
|
||||||
asset.resizePath = jpegThumbnailPath;
|
asset.resizePath = jpegThumbnailPath;
|
||||||
|
|
||||||
await this.thumbnailGeneratorQueue.add(
|
await this.thumbnailGeneratorQueue.add(generateWEBPThumbnailProcessorName, { asset }, { jobId: randomUUID() });
|
||||||
generateWEBPThumbnailProcessorName,
|
await this.metadataExtractionQueue.add(imageTaggingProcessorName, { asset }, { jobId: randomUUID() });
|
||||||
{ asset },
|
await this.metadataExtractionQueue.add(objectDetectionProcessorName, { asset }, { jobId: randomUUID() });
|
||||||
{ jobId: randomUUID() },
|
this.wsCommunicationGateway.server.to(asset.userId).emit('on_upload_success', JSON.stringify(mapAsset(asset)));
|
||||||
);
|
|
||||||
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) {
|
if (asset.type == AssetType.VIDEO) {
|
||||||
ffmpeg(asset.originalPath)
|
await new Promise((resolve, reject) => {
|
||||||
.outputOptions(['-ss 00:00:00.000', '-frames:v 1'])
|
ffmpeg(asset.originalPath)
|
||||||
.output(jpegThumbnailPath)
|
.outputOptions(['-ss 00:00:00.000', '-frames:v 1'])
|
||||||
.on('start', () => {
|
.output(jpegThumbnailPath)
|
||||||
Logger.log('Start Generating Video Thumbnail', 'generateJPEGThumbnail');
|
.on('start', () => {
|
||||||
})
|
Logger.log('Start Generating Video Thumbnail', 'generateJPEGThumbnail');
|
||||||
.on('error', (error) => {
|
})
|
||||||
Logger.error(`Cannot Generate Video Thumbnail ${error}`, 'generateJPEGThumbnail');
|
.on('error', (error) => {
|
||||||
// reject();
|
Logger.error(`Cannot Generate Video Thumbnail ${error}`, 'generateJPEGThumbnail');
|
||||||
})
|
reject(error);
|
||||||
.on('end', async () => {
|
})
|
||||||
Logger.log(`Generating Video Thumbnail Success ${asset.id}`, 'generateJPEGThumbnail');
|
.on('end', async () => {
|
||||||
await this.assetRepository.update({ id: asset.id }, { resizePath: jpegThumbnailPath });
|
Logger.log(`Generating Video Thumbnail Success ${asset.id}`, 'generateJPEGThumbnail');
|
||||||
|
resolve(asset);
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
});
|
||||||
|
|
||||||
// Update resize path to send to generate webp queue
|
await this.assetRepository.update({ id: asset.id }, { resizePath: jpegThumbnailPath });
|
||||||
asset.resizePath = jpegThumbnailPath;
|
|
||||||
|
|
||||||
await this.thumbnailGeneratorQueue.add(
|
// Update resize path to send to generate webp queue
|
||||||
generateWEBPThumbnailProcessorName,
|
asset.resizePath = jpegThumbnailPath;
|
||||||
{ asset },
|
|
||||||
{ jobId: randomUUID() },
|
|
||||||
);
|
|
||||||
await this.metadataExtractionQueue.add(imageTaggingProcessorName, { asset }, { jobId: randomUUID() });
|
|
||||||
await this.metadataExtractionQueue.add(objectDetectionProcessorName, { asset }, { jobId: randomUUID() });
|
|
||||||
|
|
||||||
this.wsCommunicationGateway.server
|
await this.thumbnailGeneratorQueue.add(generateWEBPThumbnailProcessorName, { asset }, { jobId: randomUUID() });
|
||||||
.to(asset.userId)
|
await this.metadataExtractionQueue.add(imageTaggingProcessorName, { asset }, { jobId: randomUUID() });
|
||||||
.emit('on_upload_success', JSON.stringify(mapAsset(asset)));
|
await this.metadataExtractionQueue.add(objectDetectionProcessorName, { asset }, { jobId: randomUUID() });
|
||||||
})
|
|
||||||
.run();
|
this.wsCommunicationGateway.server.to(asset.userId).emit('on_upload_success', JSON.stringify(mapAsset(asset)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,16 +102,10 @@ export class ThumbnailGeneratorProcessor {
|
|||||||
if (!asset.resizePath) {
|
if (!asset.resizePath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const webpPath = asset.resizePath.replace('jpeg', 'webp');
|
const webpPath = asset.resizePath.replace('jpeg', 'webp');
|
||||||
|
|
||||||
sharp(asset.resizePath)
|
await sharp(asset.resizePath).resize(250).webp().rotate().toFile(webpPath);
|
||||||
.resize(250)
|
await this.assetRepository.update({ id: asset.id }, { webpPath: webpPath });
|
||||||
.webp()
|
|
||||||
.rotate()
|
|
||||||
.toFile(webpPath, (err) => {
|
|
||||||
if (!err) {
|
|
||||||
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 { mp4ConversionProcessorName } from '@app/job/constants/job-name.constant';
|
||||||
import { videoConversionQueueName } from '@app/job/constants/queue-name.constant';
|
import { videoConversionQueueName } from '@app/job/constants/queue-name.constant';
|
||||||
import { IMp4ConversionProcessor } from '@app/job/interfaces/video-transcode.interface';
|
import { IMp4ConversionProcessor } from '@app/job/interfaces/video-transcode.interface';
|
||||||
@@ -8,8 +10,6 @@ import { Job } from 'bull';
|
|||||||
import ffmpeg from 'fluent-ffmpeg';
|
import ffmpeg from 'fluent-ffmpeg';
|
||||||
import { existsSync, mkdirSync } from 'fs';
|
import { existsSync, mkdirSync } from 'fs';
|
||||||
import { Repository } from 'typeorm';
|
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)
|
@Processor(videoConversionQueueName)
|
||||||
export class VideoTranscodeProcessor {
|
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' })
|
@Column({ type: 'uuid' })
|
||||||
assetId!: string;
|
assetId!: string;
|
||||||
|
|
||||||
@Column({ type: 'varchar', nullable: true })
|
/* General info */
|
||||||
make!: string | null;
|
@Column({ type: 'text', nullable: true, default: '' })
|
||||||
|
description!: string; // or caption
|
||||||
@Column({ type: 'varchar', nullable: true })
|
|
||||||
model!: string | null;
|
|
||||||
|
|
||||||
@Column({ type: 'varchar', nullable: true })
|
|
||||||
imageName!: string | null;
|
|
||||||
|
|
||||||
@Column({ type: 'integer', nullable: true })
|
@Column({ type: 'integer', nullable: true })
|
||||||
exifImageWidth!: number | null;
|
exifImageWidth!: number | null;
|
||||||
@@ -28,7 +23,7 @@ export class ExifEntity {
|
|||||||
@Column({ type: 'integer', nullable: true })
|
@Column({ type: 'integer', nullable: true })
|
||||||
exifImageHeight!: number | null;
|
exifImageHeight!: number | null;
|
||||||
|
|
||||||
@Column({ type: 'integer', nullable: true })
|
@Column({ type: 'bigint', nullable: true })
|
||||||
fileSizeInByte!: number | null;
|
fileSizeInByte!: number | null;
|
||||||
|
|
||||||
@Column({ type: 'varchar', nullable: true })
|
@Column({ type: 'varchar', nullable: true })
|
||||||
@@ -40,21 +35,6 @@ export class ExifEntity {
|
|||||||
@Column({ type: 'timestamptz', nullable: true })
|
@Column({ type: 'timestamptz', nullable: true })
|
||||||
modifyDate!: Date | null;
|
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 })
|
@Column({ type: 'float', nullable: true })
|
||||||
latitude!: number | null;
|
latitude!: number | null;
|
||||||
|
|
||||||
@@ -70,9 +50,38 @@ export class ExifEntity {
|
|||||||
@Column({ type: 'varchar', nullable: true })
|
@Column({ type: 'varchar', nullable: true })
|
||||||
country!: string | null;
|
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 })
|
@OneToOne(() => AssetEntity, { onDelete: 'CASCADE', nullable: true })
|
||||||
@JoinColumn({ name: 'assetId', referencedColumnName: 'id' })
|
@JoinColumn({ name: 'assetId', referencedColumnName: 'id' })
|
||||||
asset?: ExifEntity;
|
asset?: AssetEntity;
|
||||||
|
|
||||||
@Index('exif_text_searchable', { synchronize: false })
|
@Index('exif_text_searchable', { synchronize: false })
|
||||||
@Column({
|
@Column({
|
||||||
|
|||||||
@@ -18,5 +18,5 @@ export class SmartInfoEntity {
|
|||||||
|
|
||||||
@OneToOne(() => AssetEntity, { onDelete: 'CASCADE', nullable: true })
|
@OneToOne(() => AssetEntity, { onDelete: 'CASCADE', nullable: true })
|
||||||
@JoinColumn({ name: 'assetId', referencedColumnName: 'id' })
|
@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
|
* The Asset entity that was saved in the database
|
||||||
*/
|
*/
|
||||||
asset: AssetEntity;
|
asset: AssetEntity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Original file name
|
||||||
|
*/
|
||||||
|
fileName: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File size in byte
|
||||||
|
*/
|
||||||
|
fileSize: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IReverseGeocodingProcessor {
|
export interface IReverseGeocodingProcessor {
|
||||||
|
|||||||
@@ -33,6 +33,15 @@
|
|||||||
"tsConfigPath": "apps/microservices/tsconfig.app.json"
|
"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": {
|
"database": {
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"root": "libs/database",
|
"root": "libs/database",
|
||||||
|
|||||||
@@ -16,6 +16,12 @@
|
|||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"baseUrl": "./",
|
"baseUrl": "./",
|
||||||
"paths": {
|
"paths": {
|
||||||
|
"@app/common": [
|
||||||
|
"libs/common/src"
|
||||||
|
],
|
||||||
|
"@app/common/*": [
|
||||||
|
"libs/common/src/*"
|
||||||
|
],
|
||||||
"@app/database": [
|
"@app/database": [
|
||||||
"libs/database/src"
|
"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",
|
"build": "vite build",
|
||||||
"package": "svelte-kit package",
|
"package": "svelte-kit package",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"prepare": "svelte-kit sync",
|
|
||||||
"check": "svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"lint": "prettier --check --plugin-search-dir=. . && eslint .",
|
"lint": "prettier --check --plugin-search-dir=. . && eslint .",
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from './open-api';
|
export * from './open-api';
|
||||||
export * from './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 Platform {}
|
||||||
|
|
||||||
interface Session {
|
|
||||||
user?: import('@api').UserResponseDto;
|
|
||||||
}
|
|
||||||
|
|
||||||
// interface Stuff {}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,5 @@
|
|||||||
import type { ExternalFetch, GetSession, Handle } from '@sveltejs/kit';
|
import type { Handle } from '@sveltejs/kit';
|
||||||
import * as cookie from 'cookie';
|
|
||||||
import { serverApi } from '@api';
|
|
||||||
|
|
||||||
export const handle: Handle = async ({ event, resolve }) => {
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
const cookies = cookie.parse(event.request.headers.get('cookie') || '');
|
return await resolve(event);
|
||||||
|
|
||||||
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
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { AlbumResponseDto, api, ThumbnailFormat } from '@api';
|
import { AlbumResponseDto, api, ThumbnailFormat } from '@api';
|
||||||
import { createEventDispatcher, onMount } from 'svelte';
|
import { createEventDispatcher, onMount } from 'svelte';
|
||||||
import DotsVertical from 'svelte-material-icons/DotsVertical.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';
|
import CircleIconButton from '../shared-components/circle-icon-button.svelte';
|
||||||
|
|
||||||
export let album: AlbumResponseDto;
|
export let album: AlbumResponseDto;
|
||||||
|
|||||||
@@ -22,6 +22,10 @@
|
|||||||
import MenuOption from '../shared-components/context-menu/menu-option.svelte';
|
import MenuOption from '../shared-components/context-menu/menu-option.svelte';
|
||||||
import ThumbnailSelection from './thumbnail-selection.svelte';
|
import ThumbnailSelection from './thumbnail-selection.svelte';
|
||||||
import ControlAppBar from '../shared-components/control-app-bar.svelte';
|
import ControlAppBar from '../shared-components/control-app-bar.svelte';
|
||||||
|
import {
|
||||||
|
notificationController,
|
||||||
|
NotificationType
|
||||||
|
} from '../shared-components/notification/notification';
|
||||||
|
|
||||||
export let album: AlbumResponseDto;
|
export let album: AlbumResponseDto;
|
||||||
|
|
||||||
@@ -129,7 +133,11 @@
|
|||||||
album = data;
|
album = data;
|
||||||
multiSelectAsset = new Set();
|
multiSelectAsset = new Set();
|
||||||
} catch (e) {
|
} 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;
|
currentAlbumName = album.albumName;
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.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;
|
isShowAssetSelection = false;
|
||||||
} catch (e) {
|
} 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;
|
isShowShareUserSelection = false;
|
||||||
} catch (e) {
|
} 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;
|
album = data;
|
||||||
isShowShareInfoModal = false;
|
isShowShareInfoModal = false;
|
||||||
} catch (e) {
|
} 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);
|
await api.albumApi.deleteAlbum(album.id);
|
||||||
goto(backUrl);
|
goto(backUrl);
|
||||||
} catch (e) {
|
} 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
|
albumThumbnailAssetId: asset.id
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} 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;
|
isShowThumbnailSelection = false;
|
||||||
|
|||||||
@@ -7,6 +7,10 @@
|
|||||||
import CircleIconButton from '../shared-components/circle-icon-button.svelte';
|
import CircleIconButton from '../shared-components/circle-icon-button.svelte';
|
||||||
import ContextMenu from '../shared-components/context-menu/context-menu.svelte';
|
import ContextMenu from '../shared-components/context-menu/context-menu.svelte';
|
||||||
import MenuOption from '../shared-components/context-menu/menu-option.svelte';
|
import MenuOption from '../shared-components/context-menu/menu-option.svelte';
|
||||||
|
import {
|
||||||
|
notificationController,
|
||||||
|
NotificationType
|
||||||
|
} from '../shared-components/notification/notification';
|
||||||
|
|
||||||
export let album: AlbumResponseDto;
|
export let album: AlbumResponseDto;
|
||||||
|
|
||||||
@@ -24,6 +28,10 @@
|
|||||||
currentUser = data;
|
currentUser = data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error [share-info-modal] [getAllUsers]', 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 });
|
dispatch('user-deleted', { userId });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error [share-info-modal] [removeUser]', 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">
|
<script lang="ts">
|
||||||
import { createEventDispatcher, onMount } from 'svelte';
|
import { createEventDispatcher, onMount, onDestroy } from 'svelte';
|
||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
import AsserViewerNavBar from './asser-viewer-nav-bar.svelte';
|
import AsserViewerNavBar from './asser-viewer-nav-bar.svelte';
|
||||||
import ChevronRight from 'svelte-material-icons/ChevronRight.svelte';
|
import ChevronRight from 'svelte-material-icons/ChevronRight.svelte';
|
||||||
import ChevronLeft from 'svelte-material-icons/ChevronLeft.svelte';
|
import ChevronLeft from 'svelte-material-icons/ChevronLeft.svelte';
|
||||||
import PhotoViewer from './photo-viewer.svelte';
|
import PhotoViewer from './photo-viewer.svelte';
|
||||||
import DetailPanel from './detail-panel.svelte';
|
import DetailPanel from './detail-panel.svelte';
|
||||||
import { downloadAssets } from '$lib/stores/download';
|
import { downloadAssets } from '$lib/stores/download';
|
||||||
import VideoViewer from './video-viewer.svelte';
|
import VideoViewer from './video-viewer.svelte';
|
||||||
import { api, AssetResponseDto, AssetTypeEnum } from '@api';
|
import { api, AssetResponseDto, AssetTypeEnum } from '@api';
|
||||||
import { browser } from '$app/env';
|
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;
|
onMount(() => {
|
||||||
let halfRightHover = false;
|
document.addEventListener('keydown', (keyInfo) => handleKeyboardPress(keyInfo.key));
|
||||||
let isShowDetail = false;
|
});
|
||||||
|
|
||||||
onMount(() => {
|
onDestroy(() => {
|
||||||
if (browser) {
|
document.removeEventListener('keydown', (e) => {});
|
||||||
document.addEventListener('keydown', (keyInfo) => handleKeyboardPress(keyInfo.key));
|
});
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleKeyboardPress = (key: string) => {
|
const handleKeyboardPress = (key: string) => {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case 'Escape':
|
case 'Escape':
|
||||||
closeViewer();
|
closeViewer();
|
||||||
return;
|
return;
|
||||||
case 'i':
|
case 'i':
|
||||||
isShowDetail = !isShowDetail;
|
isShowDetail = !isShowDetail;
|
||||||
return;
|
return;
|
||||||
case 'ArrowLeft':
|
case 'ArrowLeft':
|
||||||
navigateAssetBackward();
|
navigateAssetBackward();
|
||||||
return;
|
return;
|
||||||
case 'ArrowRight':
|
case 'ArrowRight':
|
||||||
navigateAssetForward();
|
navigateAssetForward();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeViewer = () => {
|
const closeViewer = () => {
|
||||||
dispatch('close');
|
dispatch('close');
|
||||||
};
|
};
|
||||||
|
|
||||||
const navigateAssetForward = (e?: Event) => {
|
const navigateAssetForward = (e?: Event) => {
|
||||||
e?.stopPropagation();
|
e?.stopPropagation();
|
||||||
dispatch('navigate-forward');
|
dispatch('navigate-forward');
|
||||||
};
|
};
|
||||||
|
|
||||||
const navigateAssetBackward = (e?: Event) => {
|
const navigateAssetBackward = (e?: Event) => {
|
||||||
e?.stopPropagation();
|
e?.stopPropagation();
|
||||||
dispatch('navigate-backward');
|
dispatch('navigate-backward');
|
||||||
};
|
};
|
||||||
|
|
||||||
const showDetailInfoHandler = () => {
|
const showDetailInfoHandler = () => {
|
||||||
isShowDetail = !isShowDetail;
|
isShowDetail = !isShowDetail;
|
||||||
};
|
};
|
||||||
|
|
||||||
const downloadFile = async () => {
|
const downloadFile = async () => {
|
||||||
try {
|
try {
|
||||||
console.log(asset.exifInfo);
|
console.log(asset.exifInfo);
|
||||||
const imageName = asset.exifInfo?.imageName ? asset.exifInfo?.imageName : asset.id;
|
const imageName = asset.exifInfo?.imageName ? asset.exifInfo?.imageName : asset.id;
|
||||||
const imageExtension = asset.originalPath.split('.')[1];
|
const imageExtension = asset.originalPath.split('.')[1];
|
||||||
const imageFileName = imageName + '.' + imageExtension;
|
const imageFileName = imageName + '.' + imageExtension;
|
||||||
|
|
||||||
// If assets is already download -> return;
|
// If assets is already download -> return;
|
||||||
if ($downloadAssets[imageFileName]) {
|
if ($downloadAssets[imageFileName]) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$downloadAssets[imageFileName] = 0;
|
$downloadAssets[imageFileName] = 0;
|
||||||
|
|
||||||
const {data, status} = await api.assetApi.downloadFile(
|
const { data, status } = await api.assetApi.downloadFile(
|
||||||
asset.deviceAssetId,
|
asset.deviceAssetId,
|
||||||
asset.deviceId,
|
asset.deviceId,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
{
|
{
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
onDownloadProgress: (progressEvent) => {
|
onDownloadProgress: (progressEvent) => {
|
||||||
if (progressEvent.lengthComputable) {
|
if (progressEvent.lengthComputable) {
|
||||||
const total = progressEvent.total;
|
const total = progressEvent.total;
|
||||||
const current = progressEvent.loaded;
|
const current = progressEvent.loaded;
|
||||||
$downloadAssets[imageFileName] = Math.floor((current / total) * 100);
|
$downloadAssets[imageFileName] = Math.floor((current / total) * 100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!(data instanceof Blob)) {
|
if (!(data instanceof Blob)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 200) {
|
if (status === 200) {
|
||||||
const fileUrl = URL.createObjectURL(data);
|
const fileUrl = URL.createObjectURL(data);
|
||||||
const anchor = document.createElement('a');
|
const anchor = document.createElement('a');
|
||||||
anchor.href = fileUrl;
|
anchor.href = fileUrl;
|
||||||
anchor.download = imageFileName;
|
anchor.download = imageFileName;
|
||||||
|
|
||||||
document.body.appendChild(anchor);
|
document.body.appendChild(anchor);
|
||||||
anchor.click();
|
anchor.click();
|
||||||
document.body.removeChild(anchor);
|
document.body.removeChild(anchor);
|
||||||
|
|
||||||
URL.revokeObjectURL(fileUrl);
|
URL.revokeObjectURL(fileUrl);
|
||||||
|
|
||||||
// Remove item from download list
|
// Remove item from download list
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const copy = $downloadAssets;
|
const copy = $downloadAssets;
|
||||||
delete copy[imageFileName];
|
delete copy[imageFileName];
|
||||||
$downloadAssets = copy;
|
$downloadAssets = copy;
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('Error downloading file ', e);
|
console.error('Error downloading file ', e);
|
||||||
}
|
notificationController.show({
|
||||||
};
|
type: NotificationType.Error,
|
||||||
|
message: 'Error downloading file, check console for more details.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
id="immich-asset-viewer"
|
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 "
|
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">
|
<div class="col-start-1 col-span-4 row-start-1 row-span-1 z-[1000] transition-transform">
|
||||||
<AsserViewerNavBar
|
<AsserViewerNavBar
|
||||||
on:goBack={closeViewer}
|
on:goBack={closeViewer}
|
||||||
on:showDetail={showDetailInfoHandler}
|
on:showDetail={showDetailInfoHandler}
|
||||||
on:download={downloadFile}
|
on:download={downloadFile}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class={`row-start-2 row-span-end col-start-1 col-span-2 flex place-items-center hover:cursor-pointer w-3/4 ${
|
class={`row-start-2 row-span-end col-start-1 col-span-2 flex place-items-center hover:cursor-pointer w-3/4 ${
|
||||||
asset.type === AssetTypeEnum.Video ? '' : 'z-[999]'
|
asset.type === AssetTypeEnum.Video ? '' : 'z-[999]'
|
||||||
}`}
|
}`}
|
||||||
on:mouseenter={() => {
|
on:mouseenter={() => {
|
||||||
halfLeftHover = true;
|
halfLeftHover = true;
|
||||||
halfRightHover = false;
|
halfRightHover = false;
|
||||||
}}
|
}}
|
||||||
on:mouseleave={() => {
|
on:mouseleave={() => {
|
||||||
halfLeftHover = false;
|
halfLeftHover = false;
|
||||||
}}
|
}}
|
||||||
on:click={navigateAssetBackward}
|
on:click={navigateAssetBackward}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="rounded-full p-3 hover:bg-gray-500 hover:text-gray-700 z-[1000] text-gray-500 mx-4"
|
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}
|
class:navigation-button-hover={halfLeftHover}
|
||||||
on:click={navigateAssetBackward}
|
on:click={navigateAssetBackward}
|
||||||
>
|
>
|
||||||
<ChevronLeft size="36"/>
|
<ChevronLeft size="36" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row-start-1 row-span-full col-start-1 col-span-4">
|
<div class="row-start-1 row-span-full col-start-1 col-span-4">
|
||||||
{#key asset.id}
|
{#key asset.id}
|
||||||
{#if asset.type === AssetTypeEnum.Image}
|
{#if asset.type === AssetTypeEnum.Image}
|
||||||
<PhotoViewer assetId={asset.id} deviceId={asset.deviceId} on:close={closeViewer}/>
|
<PhotoViewer assetId={asset.id} deviceId={asset.deviceId} on:close={closeViewer} />
|
||||||
{:else}
|
{:else}
|
||||||
<VideoViewer assetId={asset.id} on:close={closeViewer}/>
|
<VideoViewer assetId={asset.id} on:close={closeViewer} />
|
||||||
{/if}
|
{/if}
|
||||||
{/key}
|
{/key}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class={`row-start-2 row-span-full col-start-3 col-span-2 flex justify-end place-items-center hover:cursor-pointer w-3/4 justify-self-end ${
|
class={`row-start-2 row-span-full col-start-3 col-span-2 flex justify-end place-items-center hover:cursor-pointer w-3/4 justify-self-end ${
|
||||||
asset.type === AssetTypeEnum.Video ? '' : 'z-[500]'
|
asset.type === AssetTypeEnum.Video ? '' : 'z-[500]'
|
||||||
}`}
|
}`}
|
||||||
on:click={navigateAssetForward}
|
on:click={navigateAssetForward}
|
||||||
on:mouseenter={() => {
|
on:mouseenter={() => {
|
||||||
halfLeftHover = false;
|
halfLeftHover = false;
|
||||||
halfRightHover = true;
|
halfRightHover = true;
|
||||||
}}
|
}}
|
||||||
on:mouseleave={() => {
|
on:mouseleave={() => {
|
||||||
halfRightHover = false;
|
halfRightHover = false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="rounded-full p-3 hover:bg-gray-500 hover:text-gray-700 text-gray-500 mx-4 z-[1000]"
|
class="rounded-full p-3 hover:bg-gray-500 hover:text-gray-700 text-gray-500 mx-4 z-[1000]"
|
||||||
class:navigation-button-hover={halfRightHover}
|
class:navigation-button-hover={halfRightHover}
|
||||||
on:click={navigateAssetForward}
|
on:click={navigateAssetForward}
|
||||||
>
|
>
|
||||||
<ChevronRight size="36"/>
|
<ChevronRight size="36" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if isShowDetail}
|
{#if isShowDetail}
|
||||||
<div
|
<div
|
||||||
transition:fly={{ duration: 150 }}
|
transition:fly={{ duration: 150 }}
|
||||||
id="detail-panel"
|
id="detail-panel"
|
||||||
class="bg-immich-bg w-[360px] row-span-full transition-all "
|
class="bg-immich-bg w-[360px] row-span-full transition-all "
|
||||||
translate="yes"
|
translate="yes"
|
||||||
>
|
>
|
||||||
<DetailPanel {asset} on:close={() => (isShowDetail = false)}/>
|
<DetailPanel {asset} on:close={() => (isShowDetail = false)} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.navigation-button-hover {
|
.navigation-button-hover {
|
||||||
background-color: rgb(107 114 128 / var(--tw-bg-opacity));
|
background-color: rgb(107 114 128 / var(--tw-bg-opacity));
|
||||||
color: rgb(55 65 81 / var(--tw-text-opacity));
|
color: rgb(55 65 81 / var(--tw-text-opacity));
|
||||||
transition: all 150ms;
|
transition: all 150ms;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
import { createEventDispatcher, onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
||||||
import { api, AssetResponseDto } from '@api';
|
import { api, AssetResponseDto } from '@api';
|
||||||
|
|
||||||
@@ -10,8 +10,6 @@
|
|||||||
|
|
||||||
let assetInfo: AssetResponseDto;
|
let assetInfo: AssetResponseDto;
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const { data } = await api.assetApi.getAssetById(assetId);
|
const { data } = await api.assetApi.getAssetById(assetId);
|
||||||
assetInfo = data;
|
assetInfo = data;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
import { createEventDispatcher, onMount } from 'svelte';
|
import { createEventDispatcher, onMount } from 'svelte';
|
||||||
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
||||||
import { api, AssetResponseDto } from '@api';
|
import { api, AssetResponseDto, getFileUrl } from '@api';
|
||||||
|
|
||||||
export let assetId: string;
|
export let assetId: string;
|
||||||
|
|
||||||
@@ -13,48 +13,32 @@
|
|||||||
|
|
||||||
let videoPlayerNode: HTMLVideoElement;
|
let videoPlayerNode: HTMLVideoElement;
|
||||||
let isVideoLoading = true;
|
let isVideoLoading = true;
|
||||||
|
let videoUrl: string;
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const { data: assetInfo } = await api.assetApi.getAssetById(assetId);
|
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;
|
isVideoLoading = true;
|
||||||
|
|
||||||
try {
|
videoUrl = getFileUrl(assetInfo.deviceAssetId, assetInfo.deviceId, false, true);
|
||||||
const { data } = await api.assetApi.serveFile(
|
|
||||||
asset.deviceAssetId,
|
|
||||||
asset.deviceId,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
{
|
|
||||||
responseType: 'blob'
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!(data instanceof Blob)) {
|
return assetInfo;
|
||||||
return;
|
};
|
||||||
}
|
|
||||||
|
|
||||||
const videoData = URL.createObjectURL(data);
|
const handleCanPlay = (ev: Event) => {
|
||||||
videoPlayerNode.src = videoData;
|
const playerNode = ev.target as HTMLVideoElement;
|
||||||
|
|
||||||
videoPlayerNode.load();
|
playerNode.muted = true;
|
||||||
|
playerNode.play();
|
||||||
|
playerNode.muted = false;
|
||||||
|
|
||||||
videoPlayerNode.oncanplay = () => {
|
isVideoLoading = false;
|
||||||
videoPlayerNode.muted = true;
|
|
||||||
videoPlayerNode.play();
|
|
||||||
videoPlayerNode.muted = false;
|
|
||||||
|
|
||||||
isVideoLoading = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
return videoData;
|
|
||||||
} catch (e) {}
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -63,7 +47,13 @@
|
|||||||
class="flex place-items-center place-content-center h-full select-none"
|
class="flex place-items-center place-content-center h-full select-none"
|
||||||
>
|
>
|
||||||
{#if asset}
|
{#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" />
|
<track kind="captions" />
|
||||||
</video>
|
</video>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
import { api, UserResponseDto } from '@api';
|
import { api, UserResponseDto } from '@api';
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
import AccountEditOutline from 'svelte-material-icons/AccountEditOutline.svelte';
|
import AccountEditOutline from 'svelte-material-icons/AccountEditOutline.svelte';
|
||||||
|
import {
|
||||||
|
notificationController,
|
||||||
|
NotificationType
|
||||||
|
} from '../shared-components/notification/notification';
|
||||||
|
|
||||||
export let user: UserResponseDto;
|
export let user: UserResponseDto;
|
||||||
|
|
||||||
@@ -29,7 +33,11 @@
|
|||||||
dispatch('edit-success');
|
dispatch('edit-success');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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) {
|
} 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>
|
</script>
|
||||||
|
|||||||
@@ -8,19 +8,25 @@
|
|||||||
export let size: number = 48;
|
export let size: number = 48;
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
const getUserAvatar = async () => {
|
|
||||||
try {
|
|
||||||
const { data } = await api.userApi.getProfileImage(user.id, {
|
|
||||||
responseType: 'blob'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (data instanceof Blob) {
|
const getUserAvatar = async () => {
|
||||||
return URL.createObjectURL(data);
|
const { data } = await api.userApi.getProfileImage(user.id, {
|
||||||
}
|
responseType: 'blob'
|
||||||
} catch (e) {
|
});
|
||||||
return '/favicon.png';
|
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
{#await getUserAvatar()}
|
{#await getUserAvatar()}
|
||||||
@@ -41,4 +47,17 @@
|
|||||||
title={user.email}
|
title={user.email}
|
||||||
/>
|
/>
|
||||||
</button>
|
</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}
|
{/await}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import PlayCircleOutline from 'svelte-material-icons/PlayCircleOutline.svelte';
|
import PlayCircleOutline from 'svelte-material-icons/PlayCircleOutline.svelte';
|
||||||
import PauseCircleOutline from 'svelte-material-icons/PauseCircleOutline.svelte';
|
import PauseCircleOutline from 'svelte-material-icons/PauseCircleOutline.svelte';
|
||||||
import LoadingSpinner from './loading-spinner.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();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
export let isExisted: boolean = false;
|
export let isExisted: boolean = false;
|
||||||
|
|
||||||
let imageData: string;
|
let imageData: string;
|
||||||
let videoData: string;
|
// let videoData: string;
|
||||||
|
|
||||||
let mouseOver: boolean = false;
|
let mouseOver: boolean = false;
|
||||||
$: dispatch('mouseEvent', { isMouseOver: mouseOver, selectedGroupIndex: groupIndex });
|
$: dispatch('mouseEvent', { isMouseOver: mouseOver, selectedGroupIndex: groupIndex });
|
||||||
@@ -28,7 +28,8 @@
|
|||||||
let isThumbnailVideoPlaying = false;
|
let isThumbnailVideoPlaying = false;
|
||||||
let calculateVideoDurationIntervalHandler: NodeJS.Timer;
|
let calculateVideoDurationIntervalHandler: NodeJS.Timer;
|
||||||
let videoProgress = '00:00';
|
let videoProgress = '00:00';
|
||||||
let videoAbortController: AbortController;
|
// let videoAbortController: AbortController;
|
||||||
|
let videoUrl: string;
|
||||||
|
|
||||||
const loadImageData = async () => {
|
const loadImageData = async () => {
|
||||||
const { data } = await api.assetApi.getAssetThumbnail(asset.id, format, {
|
const { data } = await api.assetApi.getAssetThumbnail(asset.id, format, {
|
||||||
@@ -42,51 +43,8 @@
|
|||||||
|
|
||||||
const loadVideoData = async () => {
|
const loadVideoData = async () => {
|
||||||
isThumbnailVideoPlaying = false;
|
isThumbnailVideoPlaying = false;
|
||||||
videoAbortController = new AbortController();
|
|
||||||
|
|
||||||
try {
|
videoUrl = getFileUrl(asset.deviceAssetId, asset.deviceId, false, true);
|
||||||
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) {}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getVideoDurationInString = (currentTime: number) => {
|
const getVideoDurationInString = (currentTime: number) => {
|
||||||
@@ -136,12 +94,7 @@
|
|||||||
|
|
||||||
const handleMouseLeaveThumbnail = () => {
|
const handleMouseLeaveThumbnail = () => {
|
||||||
mouseOver = false;
|
mouseOver = false;
|
||||||
|
videoUrl = '';
|
||||||
// Stop XHR download of video
|
|
||||||
videoAbortController?.abort();
|
|
||||||
|
|
||||||
// Stop video playback
|
|
||||||
URL.revokeObjectURL(videoData);
|
|
||||||
|
|
||||||
clearInterval(calculateVideoDurationIntervalHandler);
|
clearInterval(calculateVideoDurationIntervalHandler);
|
||||||
|
|
||||||
@@ -149,6 +102,18 @@
|
|||||||
videoProgress = '00:00';
|
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 = () => {
|
$: getThumbnailBorderStyle = () => {
|
||||||
if (selected) {
|
if (selected) {
|
||||||
return 'border-[20px] border-immich-primary/20';
|
return 'border-[20px] border-immich-primary/20';
|
||||||
@@ -259,17 +224,21 @@
|
|||||||
|
|
||||||
{#if mouseOver && asset.type === AssetTypeEnum.Video}
|
{#if mouseOver && asset.type === AssetTypeEnum.Video}
|
||||||
<div class="absolute w-full h-full top-0" on:mouseenter={loadVideoData}>
|
<div class="absolute w-full h-full top-0" on:mouseenter={loadVideoData}>
|
||||||
<video
|
{#if videoUrl}
|
||||||
muted
|
<video
|
||||||
autoplay
|
muted
|
||||||
preload="none"
|
autoplay
|
||||||
class="h-full object-cover"
|
preload="none"
|
||||||
width="250px"
|
class="h-full object-cover"
|
||||||
style:width={`${thumbnailSize}px`}
|
width="250px"
|
||||||
bind:this={videoPlayerNode}
|
style:width={`${thumbnailSize}px`}
|
||||||
>
|
on:canplay={handleCanPlay}
|
||||||
<track kind="captions" />
|
bind:this={videoPlayerNode}
|
||||||
</video>
|
>
|
||||||
|
<source src={videoUrl} type="video/mp4" />
|
||||||
|
<track kind="captions" />
|
||||||
|
</video>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import type { ImmichUser } from '$lib/models/immich-user';
|
|
||||||
import { createEventDispatcher, onMount } from 'svelte';
|
import { createEventDispatcher, onMount } from 'svelte';
|
||||||
import { fade, fly, slide } from 'svelte/transition';
|
import { fade, fly, slide } from 'svelte/transition';
|
||||||
import TrayArrowUp from 'svelte-material-icons/TrayArrowUp.svelte';
|
import TrayArrowUp from 'svelte-material-icons/TrayArrowUp.svelte';
|
||||||
import { clickOutside } from '../../utils/click-outside';
|
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 shouldShowAccountInfo = false;
|
||||||
let shouldShowProfileImage = false;
|
let shouldShowProfileImage = false;
|
||||||
@@ -25,7 +25,6 @@
|
|||||||
await api.userApi.getProfileImage(user.id);
|
await api.userApi.getProfileImage(user.id);
|
||||||
shouldShowProfileImage = true;
|
shouldShowProfileImage = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('User does not have a profile image');
|
|
||||||
shouldShowProfileImage = false;
|
shouldShowProfileImage = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -33,10 +32,6 @@
|
|||||||
return text?.charAt(0).toUpperCase();
|
return text?.charAt(0).toUpperCase();
|
||||||
};
|
};
|
||||||
|
|
||||||
const navigateToAdmin = () => {
|
|
||||||
goto('/admin');
|
|
||||||
};
|
|
||||||
|
|
||||||
const showAccountInfoPanel = () => {
|
const showAccountInfoPanel = () => {
|
||||||
shouldShowAccountInfoPanel = true;
|
shouldShowAccountInfoPanel = true;
|
||||||
};
|
};
|
||||||
@@ -60,7 +55,7 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<section class="flex gap-4 place-items-center">
|
<section class="flex gap-4 place-items-center">
|
||||||
{#if $page.url.pathname !== '/admin'}
|
{#if $page.url.pathname !== '/admin' && shouldShowUploadButton}
|
||||||
<button
|
<button
|
||||||
in:fly={{ x: 50, duration: 250 }}
|
in:fly={{ x: 50, duration: 250 }}
|
||||||
on:click={() => dispatch('uploadClicked')}
|
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">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
|
|
||||||
import { AppSideBarSelection } from '$lib/models/admin-sidebar-selection';
|
import { AppSideBarSelection } from '$lib/models/admin-sidebar-selection';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
@@ -24,7 +22,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section id="sidebar" class="flex flex-col gap-1 pt-8 pr-6">
|
<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
|
<SideBarButton
|
||||||
title="Photos"
|
title="Photos"
|
||||||
logo={ImageOutline}
|
logo={ImageOutline}
|
||||||
@@ -32,7 +30,7 @@
|
|||||||
isSelected={selectedAction === AppSideBarSelection.PHOTOS}
|
isSelected={selectedAction === AppSideBarSelection.PHOTOS}
|
||||||
/></a
|
/></a
|
||||||
>
|
>
|
||||||
<a sveltekit:prefetch href={$page.routeId != 'sharing' ? `/sharing` : null}>
|
<a sveltekit:prefetch href={$page.routeId !== 'sharing' ? `/sharing` : null}>
|
||||||
<SideBarButton
|
<SideBarButton
|
||||||
title="Sharing"
|
title="Sharing"
|
||||||
logo={AccountMultipleOutline}
|
logo={AccountMultipleOutline}
|
||||||
@@ -43,7 +41,7 @@
|
|||||||
<div class="text-xs ml-5 my-4">
|
<div class="text-xs ml-5 my-4">
|
||||||
<p>LIBRARY</p>
|
<p>LIBRARY</p>
|
||||||
</div>
|
</div>
|
||||||
<a sveltekit:prefetch href={$page.routeId != 'albums' ? `/albums` : null}>
|
<a sveltekit:prefetch href={$page.routeId !== 'albums' ? `/albums` : null}>
|
||||||
<SideBarButton
|
<SideBarButton
|
||||||
title="Albums"
|
title="Albums"
|
||||||
logo={ImageAlbum}
|
logo={ImageAlbum}
|
||||||
@@ -51,8 +49,8 @@
|
|||||||
isSelected={selectedAction === AppSideBarSelection.ALBUMS}
|
isSelected={selectedAction === AppSideBarSelection.ALBUMS}
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
<!-- Status Box -->
|
|
||||||
|
|
||||||
|
<!-- Status Box -->
|
||||||
<div class="mb-6 mt-auto">
|
<div class="mb-6 mt-auto">
|
||||||
<StatusBox />
|
<StatusBox />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
const { data: serverInfoRes } = await api.serverInfoApi.getServerInfo();
|
const { data: serverInfoRes } = await api.serverInfoApi.getServerInfo();
|
||||||
serverInfo = serverInfoRes;
|
serverInfo = serverInfoRes;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('Error [StatusBox] [pingServerInterval]');
|
console.log('Error [StatusBox] [pingServerInterval]', e);
|
||||||
isServerOk = false;
|
isServerOk = false;
|
||||||
}
|
}
|
||||||
}, 10000);
|
}, 10000);
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
import WindowMinimize from 'svelte-material-icons/WindowMinimize.svelte';
|
import WindowMinimize from 'svelte-material-icons/WindowMinimize.svelte';
|
||||||
import type { UploadAsset } from '$lib/models/upload-asset';
|
import type { UploadAsset } from '$lib/models/upload-asset';
|
||||||
import { getAssetsInfo } from '$lib/stores/assets';
|
import { getAssetsInfo } from '$lib/stores/assets';
|
||||||
import { session } from '$app/stores';
|
|
||||||
let showDetail = true;
|
let showDetail = true;
|
||||||
|
|
||||||
let uploadLength = 0;
|
let uploadLength = 0;
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
|
import {
|
||||||
|
notificationController,
|
||||||
|
NotificationType
|
||||||
|
} from './../components/shared-components/notification/notification';
|
||||||
/* @vite-ignore */
|
/* @vite-ignore */
|
||||||
import * as exifr from 'exifr';
|
import * as exifr from 'exifr';
|
||||||
import { uploadAssetsStore } from '$lib/stores/upload';
|
import { uploadAssetsStore } from '$lib/stores/upload';
|
||||||
import type { UploadAsset } from '../models/upload-asset';
|
import type { UploadAsset } from '../models/upload-asset';
|
||||||
import { api, AssetFileUploadResponseDto } from '@api';
|
import { api, AssetFileUploadResponseDto } from '@api';
|
||||||
import { albumUploadAssetStore } from '$lib/stores/album-upload-asset';
|
import { albumUploadAssetStore } from '$lib/stores/album-upload-asset';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine if the upload is for album or for the user general backup
|
* Determine if the upload is for album or for the user general backup
|
||||||
* @variant GENERAL - Upload assets to the server for 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.type = 'file';
|
||||||
fileSelector.multiple = true;
|
fileSelector.multiple = true;
|
||||||
fileSelector.accept = 'image/*,video/*,.heic,.heif';
|
fileSelector.accept = 'image/*,video/*,.heic,.heif,.dng';
|
||||||
|
|
||||||
fileSelector.onchange = async (e: any) => {
|
fileSelector.onchange = async (e: any) => {
|
||||||
const files = Array.from<File>(e.target.files);
|
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(
|
const acceptedFile = files.filter(
|
||||||
(e) => e.type.split('/')[0] === 'video' || e.type.split('/')[0] === 'image'
|
(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">
|
<script lang="ts">
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
|
|
||||||
@@ -17,11 +6,16 @@
|
|||||||
import AnnouncementBox from '$lib/components/shared-components/announcement-box.svelte';
|
import AnnouncementBox from '$lib/components/shared-components/announcement-box.svelte';
|
||||||
import UploadPanel from '$lib/components/shared-components/upload-panel.svelte';
|
import UploadPanel from '$lib/components/shared-components/upload-panel.svelte';
|
||||||
import { onMount } from '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 shouldShowAnnouncement: boolean;
|
||||||
let localVersion: string;
|
let localVersion: string;
|
||||||
let remoteVersion: string;
|
let remoteVersion: string;
|
||||||
|
let showNavigationLoadingBar = false;
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const res = await checkAppVersion();
|
const res = await checkAppVersion();
|
||||||
@@ -30,16 +24,28 @@
|
|||||||
localVersion = res.localVersion ?? 'unknown';
|
localVersion = res.localVersion ?? 'unknown';
|
||||||
remoteVersion = res.remoteVersion ?? 'unknown';
|
remoteVersion = res.remoteVersion ?? 'unknown';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
beforeNavigate(() => {
|
||||||
|
showNavigationLoadingBar = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterNavigate(() => {
|
||||||
|
showNavigationLoadingBar = false;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
{#key url}
|
{#key $page.url}
|
||||||
<div in:fade={{ duration: 100 }}>
|
<div in:fade={{ duration: 100 }}>
|
||||||
|
{#if showNavigationLoadingBar}
|
||||||
|
<NavigationLoadingBar />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<slot />
|
<slot />
|
||||||
|
|
||||||
<DownloadPanel />
|
<DownloadPanel />
|
||||||
|
|
||||||
<UploadPanel />
|
<UploadPanel />
|
||||||
|
<NotificationList />
|
||||||
{#if shouldShowAnnouncement}
|
{#if shouldShowAnnouncement}
|
||||||
<AnnouncementBox
|
<AnnouncementBox
|
||||||
{localVersion}
|
{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">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
@@ -54,11 +10,12 @@
|
|||||||
import CreateUserForm from '$lib/components/forms/create-user-form.svelte';
|
import CreateUserForm from '$lib/components/forms/create-user-form.svelte';
|
||||||
import EditUserForm from '$lib/components/forms/edit-user-form.svelte';
|
import EditUserForm from '$lib/components/forms/edit-user-form.svelte';
|
||||||
import StatusBox from '$lib/components/shared-components/status-box.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;
|
let selectedAction: AdminSideBarSelection = AdminSideBarSelection.USER_MANAGEMENT;
|
||||||
|
|
||||||
export let user: UserResponseDto;
|
export let data: PageData;
|
||||||
export let allUsers: UserResponseDto[];
|
|
||||||
|
|
||||||
let editUser: UserResponseDto;
|
let editUser: UserResponseDto;
|
||||||
|
|
||||||
@@ -75,8 +32,8 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const onUserCreated = async () => {
|
const onUserCreated = async () => {
|
||||||
const { data } = await api.userApi.getAllUsers(false);
|
const getAllUsersRes = await api.userApi.getAllUsers(false);
|
||||||
allUsers = data;
|
data.allUsers = getAllUsersRes.data;
|
||||||
shouldShowCreateUserForm = false;
|
shouldShowCreateUserForm = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -87,14 +44,14 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onEditUserSuccess = async () => {
|
const onEditUserSuccess = async () => {
|
||||||
const { data } = await api.userApi.getAllUsers(false);
|
const getAllUsersRes = await api.userApi.getAllUsers(false);
|
||||||
allUsers = data;
|
data.allUsers = getAllUsersRes.data;
|
||||||
shouldShowEditUserForm = false;
|
shouldShowEditUserForm = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const onEditPasswordSuccess = async () => {
|
const onEditPasswordSuccess = async () => {
|
||||||
const { data } = await api.userApi.getAllUsers(false);
|
const getAllUsersRes = await api.userApi.getAllUsers(false);
|
||||||
allUsers = data;
|
data.allUsers = getAllUsersRes.data;
|
||||||
shouldShowEditUserForm = false;
|
shouldShowEditUserForm = false;
|
||||||
shouldShowInfoPanel = true;
|
shouldShowInfoPanel = true;
|
||||||
};
|
};
|
||||||
@@ -104,7 +61,7 @@
|
|||||||
<title>Administration - Immich</title>
|
<title>Administration - Immich</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<NavigationBar {user} />
|
<NavigationBar user={data.user} />
|
||||||
|
|
||||||
{#if shouldShowCreateUserForm}
|
{#if shouldShowCreateUserForm}
|
||||||
<FullScreenModal on:clickOutside={() => (shouldShowCreateUserForm = false)}>
|
<FullScreenModal on:clickOutside={() => (shouldShowCreateUserForm = false)}>
|
||||||
@@ -125,7 +82,7 @@
|
|||||||
{#if shouldShowInfoPanel}
|
{#if shouldShowInfoPanel}
|
||||||
<FullScreenModal on:clickOutside={() => (shouldShowInfoPanel = false)}>
|
<FullScreenModal on:clickOutside={() => (shouldShowInfoPanel = false)}>
|
||||||
<div class="border bg-white shadow-sm w-[500px] rounded-3xl p-8 text-sm">
|
<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>
|
<p>
|
||||||
The user's password has been reset to the default <code
|
The user's password has been reset to the default <code
|
||||||
@@ -170,7 +127,7 @@
|
|||||||
<section class="w-[800px] pt-4">
|
<section class="w-[800px] pt-4">
|
||||||
{#if selectedAction === AdminSideBarSelection.USER_MANAGEMENT}
|
{#if selectedAction === AdminSideBarSelection.USER_MANAGEMENT}
|
||||||
<UserManagement
|
<UserManagement
|
||||||
{allUsers}
|
allUsers={data.allUsers}
|
||||||
on:create-user={() => (shouldShowCreateUserForm = true)}
|
on:create-user={() => (shouldShowCreateUserForm = true)}
|
||||||
on:edit-user={editUserHandler}
|
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