Compare commits

...

21 Commits

Author SHA1 Message Date
Alex Tran
31739aca02 Up version for release 2022-09-10 11:58:59 -05:00
Thanh Pham
8f2e7b6f65 fix(server): loop on checksum generation (#662) 2022-09-10 11:52:39 -05:00
Brett Profitt
4ed647c43d fix(install): Fix checking for docker compose. (#663) 2022-09-10 11:48:50 -05:00
Alex
f88ff4fb5c fix(mobile): background backup not working in release mode (#664) 2022-09-10 11:46:51 -05:00
Alex Tran
cc4881d633 Up version for release 2022-09-09 23:23:37 -05:00
Alex
d856b35afc feat(web) add scrollbar with timeline information (#658)
- Implement a scrollbar with a timeline similar to Google Photos
- The scrollbar can also be dragged
2022-09-09 15:55:20 -05:00
Jaime Baez
b6d025da09 Fix Notification components possible memory leaks (#650)
Dispose subscriptions and timeouts when
the components are removed from the DOM
2022-09-09 07:40:35 -05:00
Jaime Baez
cc79ff1ca3 Merge pull request #642 from immich-app/add/ci-web-checks
Add web test / check commands and workflow to run in CI
2022-09-08 19:12:39 +02:00
Jaime Baez
131aa2b6be Add command to test/check code in dev-setup docs 2022-09-08 17:54:45 +02:00
Jaime Baez
02a6b73122 Add web-unit-test workflow to run in CI 2022-09-08 17:44:13 +02:00
Jaime Baez
d87366c095 Add dev-setup documentation 2022-09-08 17:41:24 +02:00
Jaime Baez
4f7a3afbfc Fix web lint issues 2022-09-08 17:30:49 +02:00
Jaime Baez
6725954b70 Add web check / lint npm commands
`svelte-check` returns some "hints" that can be ignored since some
are not true and others are not relevant.
2022-09-08 17:17:15 +02:00
Fynn Petersen-Frey
4fe535e5e8 improve Android background service reliability (#603)
This change greatly reduces the chance that a backup is not performed
when a new photo/video is made.
Instead of combining the change trigger and additonal constraints (wifi
or charging) into a single worker, these aspects are now separated.
Thus, it is now reliably possible to take pictures while the wifi
constraint is not satisfied and upload them hours/days later once
connected to wifi without taking a new photo.
As a positive side effect, this simplifies the error/retry handling
by directly leveraging Android's WorkManager without workarounds.
The separation also allows to notify the currently running BackupWorker
that new assets were added while backing up other assets to also upload
those newly added assets.
Further, a new tiny service checks if the app is killed, to reschedule
the content change worker and allow to detect the first new photo.
Bonus: The home screen now shows backup as enabled if background backup
is active.

* use separate worker/task for listening on changed/added assets
* use separate worker/task for performing the backup
* content observer worker enqueues backup worker on each new asset
* wifi/charging constraints only apply to backup worker
* backupworker is notified of assets added while running to re-run
* new service to catch app being killed to workaround WorkManager issue
2022-09-08 08:36:08 -05:00
Jaime Baez
aed94bfc4c Format web code with prettier
Added `.md` and `.json` to .prettierignore
2022-09-08 12:53:09 +02:00
Jaime Baez
de996c0a81 Merge pull request #612 from immich-app/add/web-ui-tests-setup
Add web UI components tests setup

@alextran1502 I'll get this merged so I can add CI checks for the web as well. Let me know if you have any questions 😃
2022-09-08 11:24:08 +02:00
Jaime Baez
1a39aa4da5 Merge pull request #633 from immich-app/fix/server-lint-errors
Add all server checks to CI - fix lint issues
2022-09-08 11:12:31 +02:00
Jaime Baez
1f4ba73da7 Add all server checks to CI - fix lint issues
CI will now run linter, type-checks and tests for the server.

All the lint issues have been fixed.
2022-09-08 11:07:27 +02:00
Alex Tran
836b174d33 Better styling for count info 2022-09-07 21:19:24 -05:00
Jaime Baez
6b702b13e4 Rename albums BLoC (.bloc.ts convention)
By convention now it's `album.bloc.ts`
2022-09-07 16:04:50 +02:00
Jaime Baez
f476bd985b Add AlbumCard UI tests
- add libraries for component UI testing
- implement AlbumCard UI tests
2022-09-07 16:00:57 +02:00
98 changed files with 1568 additions and 642 deletions

View File

@@ -18,8 +18,8 @@ jobs:
- name: Run Immich Server 2E2 Test - name: Run Immich Server 2E2 Test
run: docker-compose -f ./docker/docker-compose.test.yml --env-file ./docker/.env.test up --abort-on-container-exit --exit-code-from immich-server-test run: docker-compose -f ./docker/docker-compose.test.yml --env-file ./docker/.env.test up --abort-on-container-exit --exit-code-from immich-server-test
unit-tests: server-unit-tests:
name: Run unit test suites name: Run server unit test suites and checks
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@@ -27,4 +27,15 @@ jobs:
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Run tests - name: Run tests
run: cd server && npm install && npm run test run: cd server && npm ci && npm run check:all
web-unit-tests:
name: Run web unit test suites and checks
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Run tests
run: cd web && npm ci && npm run check:all

View File

@@ -27,6 +27,7 @@
- [Features](#features) - [Features](#features)
- [Screenshots](#screenshots) - [Screenshots](#screenshots)
- [Installation](#installation) - [Installation](#installation)
- [Update](#update)
- [Mobile App](#-mobile-app) - [Mobile App](#-mobile-app)
- [Development](#development) - [Development](#development)
- [Support](#support) - [Support](#support)
@@ -172,6 +173,14 @@ wget -O .env https://raw.githubusercontent.com/immich-app/immich/main/docker/.en
<br/> <br/>
## Update
If you have installed, you can update the application by navigate to the directory that contains the `docker-compose.yml` file and run the following command:
```bash
docker-compose pull && docker-compose up -d
```
# Mobile app # Mobile app
| F-Droid | Google Play | iOS | | F-Droid | Google Play | iOS |

32
dev-setup.md Normal file
View File

@@ -0,0 +1,32 @@
# Development Setup
## Lint / format extensions
Setting these in the IDE give a better developer experience auto-formatting code on save and providing instant feedback on lint issues.
### VSCode
Install Prettier, ESLint and Svelte extensions.
in User `settings.json` (`cmd + shift + p` and search for Open User Settings JSON) add the following:
```json
{
"editor.formatOnSave": true,
"[javascript][typescript][css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.tabSize": 2,
"editor.formatOnSave": true
},
"[svelte]": {
"editor.defaultFormatter": "svelte.svelte-vscode",
"editor.tabSize": 2
},
"svelte.enable-ts-plugin": true,
"eslint.validate": ["javascript", "svelte"]
}
```
## Running tests / checks
In both server and web:
`npm run check:all`

View File

@@ -6,10 +6,6 @@ RED='\033[0;31m'
GREEN='\032[0;31m' GREEN='\032[0;31m'
NC='\033[0m' # No Color NC='\033[0m' # No Color
machine_has() {
type "$1" >/dev/null 2>&1
}
create_immich_directory() { create_immich_directory() {
echo "Creating Immich directory..." echo "Creating Immich directory..."
mkdir -p ./immich-app/immich-data mkdir -p ./immich-app/immich-data
@@ -45,18 +41,21 @@ populate_upload_location() {
start_docker_compose() { start_docker_compose() {
echo "Starting Immich's docker containers" echo "Starting Immich's docker containers"
if machine_has "docker compose"; then { if docker compose &> /dev/null; then
docker compose up --remove-orphans -d docker_bin="docker compose"
elif docker-compose &> /dev/null; then
show_friendly_message docker_bin="docker-compose"
exit 0 else
}; fi echo 'Cannot find `docker compose` or `docker-compose`.'
exit 1
if machine_has "docker-compose"; then fi
docker-compose up --remove-orphans -d
if $docker_bin up --remove-orphans -d; then
show_friendly_message show_friendly_message
exit 0 exit 0
else
echo "Could not start. Check for errors above."
exit 1
fi fi
} }

View File

@@ -12,6 +12,7 @@
</intent-filter> </intent-filter>
</activity> </activity>
<service android:name=".AppClearedService" android:stopWithTask="false" />
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data android:name="flutterEmbedding" android:value="2" /> <meta-data android:name="flutterEmbedding" android:value="2" />

View File

@@ -0,0 +1,25 @@
package app.alextran.immich
import android.app.Service
import android.content.Intent
import android.os.IBinder
/**
* Catches the event when either the system or the user kills the app
* (does not apply on force close!)
*/
class AppClearedService() : Service() {
override fun onBind(intent: Intent): IBinder? {
return null
}
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
return START_NOT_STICKY;
}
override fun onTaskRemoved(rootIntent: Intent) {
ContentObserverWorker.workManagerAppClearedWorkaround(applicationContext)
stopSelf();
}
}

View File

@@ -1,11 +1,6 @@
package app.alextran.immich package app.alextran.immich
import android.content.Context import android.content.Context
import android.net.Uri
import android.content.Intent
import android.provider.Settings
import android.util.Log
import android.widget.Toast
import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.BinaryMessenger import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
@@ -44,30 +39,30 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
val ctx = context!! val ctx = context!!
when(call.method) { when(call.method) {
"initialize" -> { // needs to be called prior to any other method "enable" -> {
val args = call.arguments<ArrayList<*>>()!! val args = call.arguments<ArrayList<*>>()!!
ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE) ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
.edit().putLong(BackupWorker.SHARED_PREF_CALLBACK_KEY, args.get(0) as Long).apply() .edit()
.putLong(BackupWorker.SHARED_PREF_CALLBACK_KEY, args.get(0) as Long)
.putString(BackupWorker.SHARED_PREF_NOTIFICATION_TITLE, args.get(1) as String)
.apply()
ContentObserverWorker.enable(ctx, immediate = args.get(2) as Boolean)
result.success(true) result.success(true)
} }
"start" -> { "configure" -> {
val args = call.arguments<ArrayList<*>>()!! val args = call.arguments<ArrayList<*>>()!!
val immediate = args.get(0) as Boolean val requireUnmeteredNetwork = args.get(0) as Boolean
val keepExisting = args.get(1) as Boolean val requireCharging = args.get(1) as Boolean
val requireUnmeteredNetwork = args.get(2) as Boolean ContentObserverWorker.configureWork(ctx, requireUnmeteredNetwork, requireCharging)
val requireCharging = args.get(3) as Boolean result.success(true)
val notificationTitle = args.get(4) as String
ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
.edit().putString(BackupWorker.SHARED_PREF_NOTIFICATION_TITLE, notificationTitle).apply()
BackupWorker.startWork(ctx, immediate, keepExisting, requireUnmeteredNetwork, requireCharging)
result.success(true)
} }
"stop" -> { "disable" -> {
ContentObserverWorker.disable(ctx)
BackupWorker.stopWork(ctx) BackupWorker.stopWork(ctx)
result.success(true) result.success(true)
} }
"isEnabled" -> { "isEnabled" -> {
result.success(BackupWorker.isEnabled(ctx)) result.success(ContentObserverWorker.isEnabled(ctx))
} }
"isIgnoringBatteryOptimizations" -> { "isIgnoringBatteryOptimizations" -> {
result.success(BackupWorker.isIgnoringBatteryOptimizations(ctx)) result.success(BackupWorker.isIgnoringBatteryOptimizations(ctx))

View File

@@ -8,17 +8,12 @@ import android.os.Handler
import android.os.Looper import android.os.Looper
import android.os.PowerManager import android.os.PowerManager
import android.os.SystemClock import android.os.SystemClock
import android.provider.MediaStore
import android.provider.BaseColumns
import android.provider.MediaStore.MediaColumns
import android.provider.MediaStore.Images.Media
import android.util.Log import android.util.Log
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.concurrent.futures.ResolvableFuture import androidx.concurrent.futures.ResolvableFuture
import androidx.work.BackoffPolicy import androidx.work.BackoffPolicy
import androidx.work.Constraints import androidx.work.Constraints
import androidx.work.Data
import androidx.work.ForegroundInfo import androidx.work.ForegroundInfo
import androidx.work.ListenableWorker import androidx.work.ListenableWorker
import androidx.work.NetworkType import androidx.work.NetworkType
@@ -26,6 +21,7 @@ import androidx.work.WorkerParameters
import androidx.work.ExistingWorkPolicy import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequest import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager import androidx.work.WorkManager
import androidx.work.WorkInfo
import com.google.common.util.concurrent.ListenableFuture import com.google.common.util.concurrent.ListenableFuture
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.dart.DartExecutor import io.flutter.embedding.engine.dart.DartExecutor
@@ -41,14 +37,7 @@ import java.util.concurrent.TimeUnit
* Starts the Dart runtime/engine and calls `_nativeEntry` function in * Starts the Dart runtime/engine and calls `_nativeEntry` function in
* `background.service.dart` to run the actual backup logic. * `background.service.dart` to run the actual backup logic.
* Called by Android WorkManager when all constraints for the work are met, * Called by Android WorkManager when all constraints for the work are met,
* i.e. a new photo/video is created on the device AND battery is not low. * i.e. battery is not low and optionally Wifi and charging are active.
* Optionally, unmetered network (wifi) and charging can be required.
* As this work is not triggered periodically, but on content change, the
* worker enqueues itself again with the same settings.
* In case the worker is stopped by the system (e.g. constraints like wifi
* are no longer met, or the system needs memory resources for more other
* more important work), the worker is replaced without the constraint on
* changed contents to run again as soon as deemed possible by the system.
*/ */
class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ctx, params), MethodChannel.MethodCallHandler { class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ctx, params), MethodChannel.MethodCallHandler {
@@ -57,14 +46,13 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
private lateinit var backgroundChannel: MethodChannel private lateinit var backgroundChannel: MethodChannel
private val notificationManager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager private val notificationManager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private val isIgnoringBatteryOptimizations = isIgnoringBatteryOptimizations(applicationContext) private val isIgnoringBatteryOptimizations = isIgnoringBatteryOptimizations(applicationContext)
private var timeBackupStarted: Long = 0L
override fun startWork(): ListenableFuture<ListenableWorker.Result> { override fun startWork(): ListenableFuture<ListenableWorker.Result> {
Log.d(TAG, "startWork")
val ctx = applicationContext val ctx = applicationContext
// enqueue itself once again to continue to listen on added photos/videos
enqueueMoreWork(ctx,
requireUnmeteredNetwork = inputData.getBoolean(DATA_KEY_UNMETERED, true),
requireCharging = inputData.getBoolean(DATA_KEY_CHARGING, false))
if (!flutterLoader.initialized()) { if (!flutterLoader.initialized()) {
flutterLoader.startInitialization(ctx) flutterLoader.startInitialization(ctx)
@@ -73,14 +61,16 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
// Create a Notification channel if necessary // Create a Notification channel if necessary
createChannel() createChannel()
} }
val title = ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
.getString(SHARED_PREF_NOTIFICATION_TITLE, NOTIFICATION_DEFAULT_TITLE)!!
if (isIgnoringBatteryOptimizations) { if (isIgnoringBatteryOptimizations) {
// normal background services can only up to 10 minutes // normal background services can only up to 10 minutes
// foreground services are allowed to run indefinitely // foreground services are allowed to run indefinitely
// requires battery optimizations to be disabled (either manually by the user // requires battery optimizations to be disabled (either manually by the user
// or by the system learning that immich is important to the user) // or by the system learning that immich is important to the user)
val title = ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
.getString(SHARED_PREF_NOTIFICATION_TITLE, NOTIFICATION_DEFAULT_TITLE)!!
setForegroundAsync(createForegroundInfo(title)) setForegroundAsync(createForegroundInfo(title))
} else {
showBackgroundInfo(title)
} }
engine = FlutterEngine(ctx) engine = FlutterEngine(ctx)
@@ -115,6 +105,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
} }
override fun onStopped() { override fun onStopped() {
Log.d(TAG, "onStopped")
// called when the system has to stop this worker because constraints are // called when the system has to stop this worker because constraints are
// no longer met or the system needs resources for more important tasks // no longer met or the system needs resources for more important tasks
Handler(Looper.getMainLooper()).postAtFrontOfQueue { Handler(Looper.getMainLooper()).postAtFrontOfQueue {
@@ -130,24 +121,18 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
private fun stopEngine(result: Result?) { private fun stopEngine(result: Result?) {
if (result != null) { if (result != null) {
Log.d(TAG, "stopEngine result=${result}")
resolvableFuture.set(result) resolvableFuture.set(result)
} else if (engine != null && inputData.getInt(DATA_KEY_RETRIES, 0) == 0) {
// stopped by system and this is the first time (content change constraints active)
// replace the task without the content constraints to finish the backup as soon as possible
enqueueMoreWork(applicationContext,
immediate = true,
requireUnmeteredNetwork = inputData.getBoolean(DATA_KEY_UNMETERED, true),
requireCharging = inputData.getBoolean(DATA_KEY_CHARGING, false),
initialDelayInMs = ONE_MINUTE,
retries = inputData.getInt(DATA_KEY_RETRIES, 0) + 1)
} }
engine?.destroy() engine?.destroy()
engine = null engine = null
clearBackgroundNotification()
} }
override fun onMethodCall(call: MethodCall, r: MethodChannel.Result) { override fun onMethodCall(call: MethodCall, r: MethodChannel.Result) {
when (call.method) { when (call.method) {
"initialized" -> "initialized" -> {
timeBackupStarted = SystemClock.uptimeMillis()
backgroundChannel.invokeMethod( backgroundChannel.invokeMethod(
"onAssetsChanged", "onAssetsChanged",
null, null,
@@ -163,25 +148,18 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
override fun success(receivedResult: Any?) { override fun success(receivedResult: Any?) {
val success = receivedResult as Boolean val success = receivedResult as Boolean
stopEngine(if(success) Result.success() else Result.retry()) stopEngine(if(success) Result.success() else Result.retry())
if (!success && inputData.getInt(DATA_KEY_RETRIES, 0) == 0) {
// there was an error (e.g. server not available)
// replace the task without the content constraints to finish the backup as soon as possible
enqueueMoreWork(applicationContext,
immediate = true,
requireUnmeteredNetwork = inputData.getBoolean(DATA_KEY_UNMETERED, true),
requireCharging = inputData.getBoolean(DATA_KEY_CHARGING, false),
initialDelayInMs = ONE_MINUTE,
retries = inputData.getInt(DATA_KEY_RETRIES, 0) + 1)
}
} }
} }
) )
}
"updateNotification" -> { "updateNotification" -> {
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
if (isIgnoringBatteryOptimizations) { if (isIgnoringBatteryOptimizations) {
setForegroundAsync(createForegroundInfo(title, content)) setForegroundAsync(createForegroundInfo(title, content))
} else {
showBackgroundInfo(title, content)
} }
} }
"showError" -> { "showError" -> {
@@ -192,6 +170,14 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
showError(title, content, individualTag) showError(title, content, individualTag)
} }
"clearErrorNotifications" -> clearErrorNotifications() "clearErrorNotifications" -> clearErrorNotifications()
"hasContentChanged" -> {
val lastChange = applicationContext
.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
.getLong(SHARED_PREF_LAST_CHANGE, timeBackupStarted)
val hasContentChanged = lastChange > timeBackupStarted;
timeBackupStarted = SystemClock.uptimeMillis()
r.success(hasContentChanged)
}
else -> r.notImplemented() else -> r.notImplemented()
} }
} }
@@ -211,6 +197,22 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
notificationManager.cancel(NOTIFICATION_ERROR_ID) notificationManager.cancel(NOTIFICATION_ERROR_ID)
} }
private fun showBackgroundInfo(title: String = NOTIFICATION_DEFAULT_TITLE, content: String? = null) {
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID)
.setContentTitle(title)
.setTicker(title)
.setContentText(content)
.setSmallIcon(R.mipmap.ic_launcher)
.setOnlyAlertOnce(true)
.setOngoing(true)
.build()
notificationManager.notify(NOTIFICATION_ID, notification)
}
private fun clearBackgroundNotification() {
notificationManager.cancel(NOTIFICATION_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 {
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID) val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID)
.setContentTitle(title) .setContentTitle(title)
@@ -233,89 +235,61 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
companion object { companion object {
const val SHARED_PREF_NAME = "immichBackgroundService" const val SHARED_PREF_NAME = "immichBackgroundService"
const val SHARED_PREF_CALLBACK_KEY = "callbackDispatcherHandle" const val SHARED_PREF_CALLBACK_KEY = "callbackDispatcherHandle"
const val SHARED_PREF_SERVICE_ENABLED = "serviceEnabled"
const val SHARED_PREF_NOTIFICATION_TITLE = "notificationTitle" const val SHARED_PREF_NOTIFICATION_TITLE = "notificationTitle"
const val SHARED_PREF_LAST_CHANGE = "lastChange"
private const val TASK_NAME = "immich/photoListener" private const val TASK_NAME_BACKUP = "immich/BackupWorker"
private const val DATA_KEY_UNMETERED = "unmetered"
private const val DATA_KEY_CHARGING = "charging"
private const val DATA_KEY_RETRIES = "retries"
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_ID = 1
private const val NOTIFICATION_ERROR_ID = 2 private const val NOTIFICATION_ERROR_ID = 2
private const val ONE_MINUTE: Long = 60000 private const val ONE_MINUTE = 60000L
/** /**
* Enqueues the `BackupWorker` to run when all constraints are met. * Enqueues the BackupWorker to run once the constraints are met
*
* @param context Android Context
* @param immediate whether to enqueue(replace) the worker without the content change constraint
* @param keepExisting if true, use `ExistingWorkPolicy.KEEP`, else `ExistingWorkPolicy.APPEND_OR_REPLACE`
* @param requireUnmeteredNetwork if true, task only runs if connected to wifi
* @param requireCharging if true, task only runs if device is charging
* @param retries retry count (should be 0 unless an error occured and this is a retry)
*/ */
fun startWork(context: Context, fun enqueueBackupWorker(context: Context,
immediate: Boolean = false, requireWifi: Boolean = false,
keepExisting: Boolean = false, requireCharging: Boolean = false,
requireUnmeteredNetwork: Boolean = false, delayMilliseconds: Long = 0L) {
requireCharging: Boolean = false) { val workRequest = buildWorkRequest(requireWifi, requireCharging, delayMilliseconds)
context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) WorkManager.getInstance(context).enqueueUniqueWork(TASK_NAME_BACKUP, ExistingWorkPolicy.KEEP, workRequest)
.edit().putBoolean(SHARED_PREF_SERVICE_ENABLED, true).apply() Log.d(TAG, "enqueueBackupWorker: BackupWorker enqueued")
enqueueMoreWork(context, immediate, keepExisting, requireUnmeteredNetwork, requireCharging)
} }
private fun enqueueMoreWork(context: Context, /**
immediate: Boolean = false, * Updates the constraints of an already enqueued BackupWorker
keepExisting: Boolean = false, */
requireUnmeteredNetwork: Boolean = false, fun updateBackupWorker(context: Context,
requireCharging: Boolean = false, requireWifi: Boolean = false,
initialDelayInMs: Long = 0, requireCharging: Boolean = false) {
retries: Int = 0) { try {
if (!isEnabled(context)) { val wm = WorkManager.getInstance(context)
return val workInfoFuture = wm.getWorkInfosForUniqueWork(TASK_NAME_BACKUP)
val workInfoList = workInfoFuture.get(1000, TimeUnit.MILLISECONDS)
if (workInfoList != null) {
for (workInfo in workInfoList) {
if (workInfo.getState() == WorkInfo.State.ENQUEUED) {
val workRequest = buildWorkRequest(requireWifi, requireCharging)
wm.enqueueUniqueWork(TASK_NAME_BACKUP, ExistingWorkPolicy.REPLACE, workRequest)
Log.d(TAG, "updateBackupWorker updated BackupWorker constraints")
return
}
}
}
Log.d(TAG, "updateBackupWorker: BackupWorker not enqueued")
} catch (e: Exception) {
Log.d(TAG, "updateBackupWorker failed: ${e}")
} }
val constraints = Constraints.Builder()
.setRequiredNetworkType(if (requireUnmeteredNetwork) NetworkType.UNMETERED else NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.setRequiresCharging(requireCharging);
if (!immediate) {
constraints
.addContentUriTrigger(MediaStore.Images.Media.INTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Video.Media.INTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true)
}
val inputData = Data.Builder()
.putBoolean(DATA_KEY_CHARGING, requireCharging)
.putBoolean(DATA_KEY_UNMETERED, requireUnmeteredNetwork)
.putInt(DATA_KEY_RETRIES, retries)
.build()
val photoCheck = OneTimeWorkRequest.Builder(BackupWorker::class.java)
.setConstraints(constraints.build())
.setInputData(inputData)
.setInitialDelay(initialDelayInMs, TimeUnit.MILLISECONDS)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
ONE_MINUTE,
TimeUnit.MILLISECONDS)
.build()
val policy = if (immediate) ExistingWorkPolicy.REPLACE else (if (keepExisting) ExistingWorkPolicy.KEEP else ExistingWorkPolicy.APPEND_OR_REPLACE)
val op = WorkManager.getInstance(context).enqueueUniqueWork(TASK_NAME, policy, photoCheck)
val result = op.getResult().get()
} }
/** /**
* Stops the currently running worker (if any) and removes it from the work queue * Stops the currently running worker (if any) and removes it from the work queue
*/ */
fun stopWork(context: Context) { fun stopWork(context: Context) {
context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) WorkManager.getInstance(context).cancelUniqueWork(TASK_NAME_BACKUP)
.edit().putBoolean(SHARED_PREF_SERVICE_ENABLED, false).apply() Log.d(TAG, "stopWork: BackupWorker cancelled")
WorkManager.getInstance(context).cancelUniqueWork(TASK_NAME)
} }
/** /**
@@ -330,12 +304,21 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
return true return true
} }
/** private fun buildWorkRequest(requireWifi: Boolean = false,
* Return true if the user has enabled the background backup service requireCharging: Boolean = false,
*/ delayMilliseconds: Long = 0L): OneTimeWorkRequest {
fun isEnabled(ctx: Context): Boolean { val constraints = Constraints.Builder()
return ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) .setRequiredNetworkType(if (requireWifi) NetworkType.UNMETERED else NetworkType.CONNECTED)
.getBoolean(SHARED_PREF_SERVICE_ENABLED, false) .setRequiresBatteryNotLow(true)
.setRequiresCharging(requireCharging)
.build();
val work = OneTimeWorkRequest.Builder(BackupWorker::class.java)
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, ONE_MINUTE, TimeUnit.MILLISECONDS)
.setInitialDelay(delayMilliseconds, TimeUnit.MILLISECONDS)
.build()
return work
} }
private val flutterLoader = FlutterLoader() private val flutterLoader = FlutterLoader()

View File

@@ -0,0 +1,137 @@
package app.alextran.immich
import android.content.Context
import android.os.SystemClock
import android.provider.MediaStore
import android.util.Log
import androidx.work.Constraints
import androidx.work.Worker
import androidx.work.WorkerParameters
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import androidx.work.Operation
import java.util.concurrent.TimeUnit
/**
* Worker executed by Android WorkManager observing content changes (new photos/videos)
*
* Immediately enqueues the BackupWorker when running.
* As this work is not triggered periodically, but on content change, the
* worker enqueues itself again after each run.
*/
class ContentObserverWorker(ctx: Context, params: WorkerParameters) : Worker(ctx, params) {
override fun doWork(): Result {
if (!isEnabled(applicationContext)) {
return Result.failure()
}
if (getTriggeredContentUris().size > 0) {
startBackupWorker(applicationContext, delayMilliseconds = 0)
}
enqueueObserverWorker(applicationContext, ExistingWorkPolicy.REPLACE)
return Result.success()
}
companion object {
const val SHARED_PREF_SERVICE_ENABLED = "serviceEnabled"
const val SHARED_PREF_REQUIRE_WIFI = "requireWifi"
const val SHARED_PREF_REQUIRE_CHARGING = "requireCharging"
private const val TASK_NAME_OBSERVER = "immich/ContentObserver"
/**
* Enqueues the `ContentObserverWorker`.
*
* @param context Android Context
*/
fun enable(context: Context, immediate: Boolean = false) {
// migration to remove any old active background task
WorkManager.getInstance(context).cancelUniqueWork("immich/photoListener")
enqueueObserverWorker(context, ExistingWorkPolicy.KEEP)
Log.d(TAG, "enabled ContentObserverWorker")
if (immediate) {
startBackupWorker(context, delayMilliseconds = 5000)
}
}
/**
* Configures the `BackupWorker` to run when all constraints are met.
*
* @param context Android Context
* @param requireWifi if true, task only runs if connected to wifi
* @param requireCharging if true, task only runs if device is charging
*/
fun configureWork(context: Context,
requireWifi: Boolean = false,
requireCharging: Boolean = false) {
context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
.edit()
.putBoolean(SHARED_PREF_SERVICE_ENABLED, true)
.putBoolean(SHARED_PREF_REQUIRE_WIFI, requireWifi)
.putBoolean(SHARED_PREF_REQUIRE_CHARGING, requireCharging)
.apply()
BackupWorker.updateBackupWorker(context, requireWifi, requireCharging)
}
/**
* Stops the currently running worker (if any) and removes it from the work queue
*/
fun disable(context: Context) {
context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
.edit().putBoolean(SHARED_PREF_SERVICE_ENABLED, false).apply()
WorkManager.getInstance(context).cancelUniqueWork(TASK_NAME_OBSERVER)
Log.d(TAG, "disabled ContentObserverWorker")
}
/**
* Return true if the user has enabled the background backup service
*/
fun isEnabled(ctx: Context): Boolean {
return ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
.getBoolean(SHARED_PREF_SERVICE_ENABLED, false)
}
/**
* Enqueue and replace the worker without the content trigger but with a short delay
*/
fun workManagerAppClearedWorkaround(context: Context) {
val work = OneTimeWorkRequest.Builder(ContentObserverWorker::class.java)
.setInitialDelay(500, TimeUnit.MILLISECONDS)
.build()
WorkManager
.getInstance(context)
.enqueueUniqueWork(TASK_NAME_OBSERVER, ExistingWorkPolicy.REPLACE, work)
.getResult()
.get()
Log.d(TAG, "workManagerAppClearedWorkaround")
}
private fun enqueueObserverWorker(context: Context, policy: ExistingWorkPolicy) {
val constraints = Constraints.Builder()
.addContentUriTrigger(MediaStore.Images.Media.INTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Video.Media.INTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true)
.setTriggerContentUpdateDelay(5000, TimeUnit.MILLISECONDS)
.build()
val work = OneTimeWorkRequest.Builder(ContentObserverWorker::class.java)
.setConstraints(constraints)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(TASK_NAME_OBSERVER, policy, work)
}
private fun startBackupWorker(context: Context, delayMilliseconds: Long) {
val sp = context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
val requireWifi = sp.getBoolean(SHARED_PREF_REQUIRE_WIFI, true)
val requireCharging = sp.getBoolean(SHARED_PREF_REQUIRE_CHARGING, false)
BackupWorker.enqueueBackupWorker(context, requireWifi, requireCharging, delayMilliseconds)
sp.edit().putLong(BackupWorker.SHARED_PREF_LAST_CHANGE, SystemClock.uptimeMillis()).apply()
}
}
}
private const val TAG = "ContentObserverWorker"

View File

@@ -2,6 +2,8 @@ package app.alextran.immich
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import android.os.Bundle
import android.content.Intent
class MainActivity: FlutterActivity() { class MainActivity: FlutterActivity() {
@@ -10,4 +12,9 @@ class MainActivity: FlutterActivity() {
flutterEngine.getPlugins().add(BackgroundServicePlugin()) flutterEngine.getPlugins().add(BackgroundServicePlugin())
} }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
startService(Intent(getBaseContext(), AppClearedService::class.java));
}
} }

View File

@@ -30,8 +30,8 @@ platform :android do
task: 'bundle', task: 'bundle',
build_type: 'Release', build_type: 'Release',
properties: { properties: {
"android.injected.version.code" => 38, "android.injected.version.code" => 40,
"android.injected.version.name" => "1.28.0", "android.injected.version.name" => "1.28.2",
} }
) )
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

View File

@@ -0,0 +1 @@
* Improve Android background service reliability

View File

@@ -0,0 +1 @@
* Fix background service cannot run in release build

View File

@@ -19,7 +19,7 @@ platform :ios do
desc "iOS Beta" desc "iOS Beta"
lane :beta do lane :beta do
increment_version_number( increment_version_number(
version_number: "1.28.0" version_number: "1.28.2"
) )
increment_build_number( increment_build_number(
build_number: latest_testflight_build_number + 1, build_number: latest_testflight_build_number + 1,

View File

@@ -4,7 +4,6 @@ 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';
@@ -33,7 +32,6 @@ class BackgroundService {
MethodChannel('immich/foregroundChannel'); MethodChannel('immich/foregroundChannel');
static const MethodChannel _backgroundChannel = static const MethodChannel _backgroundChannel =
MethodChannel('immich/backgroundChannel'); MethodChannel('immich/backgroundChannel');
bool _isForegroundInitialized = false;
bool _isBackgroundInitialized = false; bool _isBackgroundInitialized = false;
CancellationToken? _cancellationToken; CancellationToken? _cancellationToken;
bool _canceledBySystem = false; bool _canceledBySystem = false;
@@ -43,32 +41,34 @@ class BackgroundService {
ReceivePort? _rp; ReceivePort? _rp;
bool _errorGracePeriodExceeded = true; bool _errorGracePeriodExceeded = true;
bool get isForegroundInitialized {
return _isForegroundInitialized;
}
bool get isBackgroundInitialized { bool get isBackgroundInitialized {
return _isBackgroundInitialized; return _isBackgroundInitialized;
} }
Future<bool> _initialize() async {
final callback = PluginUtilities.getCallbackHandle(_nativeEntry)!;
var result = await _foregroundChannel
.invokeMethod('initialize', [callback.toRawHandle()]);
_isForegroundInitialized = true;
return result;
}
/// Ensures that the background service is enqueued if enabled in settings /// Ensures that the background service is enqueued if enabled in settings
Future<bool> resumeServiceIfEnabled() async { Future<bool> resumeServiceIfEnabled() async {
return await isBackgroundBackupEnabled() && return await isBackgroundBackupEnabled() && await enableService();
await startService(keepExisting: true);
} }
/// Enqueues the background service /// Enqueues the background service
Future<bool> startService({ Future<bool> enableService({bool immediate = false}) async {
bool immediate = false, if (!Platform.isAndroid) {
bool keepExisting = false, return true;
}
try {
final callback = PluginUtilities.getCallbackHandle(_nativeEntry)!;
final String title =
"backup_background_service_default_notification".tr();
final bool ok = await _foregroundChannel
.invokeMethod('enable', [callback.toRawHandle(), title, immediate]);
return ok;
} catch (error) {
return false;
}
}
/// Configures the background service
Future<bool> configureService({
bool requireUnmetered = true, bool requireUnmetered = true,
bool requireCharging = false, bool requireCharging = false,
}) async { }) async {
@@ -76,14 +76,9 @@ class BackgroundService {
return true; return true;
} }
try { try {
if (!_isForegroundInitialized) {
await _initialize();
}
final String title =
"backup_background_service_default_notification".tr();
final bool ok = await _foregroundChannel.invokeMethod( final bool ok = await _foregroundChannel.invokeMethod(
'start', 'configure',
[immediate, keepExisting, requireUnmetered, requireCharging, title], [requireUnmetered, requireCharging],
); );
return ok; return ok;
} catch (error) { } catch (error) {
@@ -92,15 +87,12 @@ class BackgroundService {
} }
/// Cancels the background service (if currently running) and removes it from work queue /// Cancels the background service (if currently running) and removes it from work queue
Future<bool> stopService() async { Future<bool> disableService() async {
if (!Platform.isAndroid) { if (!Platform.isAndroid) {
return true; return true;
} }
try { try {
if (!_isForegroundInitialized) { final ok = await _foregroundChannel.invokeMethod('disable');
await _initialize();
}
final ok = await _foregroundChannel.invokeMethod('stop');
return ok; return ok;
} catch (error) { } catch (error) {
return false; return false;
@@ -113,9 +105,6 @@ class BackgroundService {
return false; return false;
} }
try { try {
if (!_isForegroundInitialized) {
await _initialize();
}
return await _foregroundChannel.invokeMethod("isEnabled"); return await _foregroundChannel.invokeMethod("isEnabled");
} catch (error) { } catch (error) {
return false; return false;
@@ -128,9 +117,6 @@ class BackgroundService {
return true; return true;
} }
try { try {
if (!_isForegroundInitialized) {
await _initialize();
}
return await _foregroundChannel return await _foregroundChannel
.invokeMethod('isIgnoringBatteryOptimizations'); .invokeMethod('isIgnoringBatteryOptimizations');
} catch (error) { } catch (error) {
@@ -187,7 +173,8 @@ class BackgroundService {
} }
} catch (error) { } catch (error) {
debugPrint( debugPrint(
"[_clearErrorNotifications] failed to communicate with plugin"); "[_clearErrorNotifications] failed to communicate with plugin",
);
} }
return false; return false;
} }
@@ -289,18 +276,11 @@ class BackgroundService {
try { try {
final bool hasAccess = await acquireLock(); final bool hasAccess = await acquireLock();
if (!hasAccess) { if (!hasAccess) {
debugPrint("[_callHandler] could acquire lock, exiting"); debugPrint("[_callHandler] could not acquire lock, exiting");
return false; return false;
} }
await translationsLoaded; await translationsLoaded;
final bool ok = 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; return ok;
} catch (error) { } catch (error) {
debugPrint(error.toString()); debugPrint(error.toString());
@@ -343,6 +323,31 @@ class BackgroundService {
} }
await PhotoManager.setIgnorePermissionCheck(true); await PhotoManager.setIgnorePermissionCheck(true);
do {
final bool backupOk = await _runBackup(backupService, backupAlbumInfo);
if (backupOk) {
await Hive.box(backgroundBackupInfoBox).delete(backupFailedSince);
await box.put(
backupInfoKey,
backupAlbumInfo,
);
} else if (Hive.box(backgroundBackupInfoBox).get(backupFailedSince) ==
null) {
Hive.box(backgroundBackupInfoBox)
.put(backupFailedSince, DateTime.now());
return false;
}
// check for new assets added while performing backup
} while (true ==
await _backgroundChannel.invokeMethod<bool>("hasContentChanged"));
return true;
}
Future<bool> _runBackup(
BackupService backupService,
HiveBackupAlbums backupAlbumInfo,
) async {
_errorGracePeriodExceeded = _isErrorGracePeriodExceeded(); _errorGracePeriodExceeded = _isErrorGracePeriodExceeded();
if (_canceledBySystem) { if (_canceledBySystem) {
@@ -382,10 +387,6 @@ class BackgroundService {
); );
if (ok) { if (ok) {
_clearErrorNotifications(); _clearErrorNotifications();
await box.put(
backupInfoKey,
backupAlbumInfo,
);
} else { } else {
_showErrorNotification( _showErrorNotification(
title: "backup_background_service_error_title".tr(), title: "backup_background_service_error_title".tr(),
@@ -447,6 +448,7 @@ class BackgroundService {
} }
/// 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
@pragma('vm:entry-point')
void _nativeEntry() { void _nativeEntry() {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
BackgroundService backgroundService = BackgroundService(); BackgroundService backgroundService = BackgroundService();

View File

@@ -131,13 +131,15 @@ class BackupNotifier extends StateNotifier<BackUpState> {
); );
if (state.backgroundBackup) { if (state.backgroundBackup) {
bool success = true;
if (!wasEnabled) { if (!wasEnabled) {
if (!await _backgroundService.isIgnoringBatteryOptimizations()) { if (!await _backgroundService.isIgnoringBatteryOptimizations()) {
onBatteryInfo(); onBatteryInfo();
} }
success &= await _backgroundService.enableService(immediate: true);
} }
final bool success = await _backgroundService.stopService() && success &= success &&
await _backgroundService.startService( await _backgroundService.configureService(
requireUnmetered: state.backupRequireWifi, requireUnmetered: state.backupRequireWifi,
requireCharging: state.backupRequireCharging, requireCharging: state.backupRequireCharging,
); );
@@ -155,7 +157,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
onError("backup_controller_page_background_configure_error"); onError("backup_controller_page_background_configure_error");
} }
} else { } else {
final bool success = await _backgroundService.stopService(); final bool success = await _backgroundService.disableService();
if (!success) { if (!success) {
state = state.copyWith(backgroundBackup: wasEnabled); state = state.copyWith(backgroundBackup: wasEnabled);
onError("backup_controller_page_background_configure_error"); onError("backup_controller_page_background_configure_error");

View File

@@ -173,19 +173,19 @@ class BackupControllerPage extends HookConsumerWidget {
).tr(), ).tr(),
), ),
actions: [ actions: [
TextButton( OutlinedButton(
onPressed: () => launchUrl( onPressed: () => launchUrl(
Uri.parse('https://dontkillmyapp.com'), Uri.parse('https://dontkillmyapp.com'),
mode: LaunchMode.externalApplication), mode: LaunchMode.externalApplication,
child: Text( ),
child: const Text(
"backup_controller_page_background_battery_info_link", "backup_controller_page_background_battery_info_link",
style: TextStyle(color: buttonTextColor),
).tr(), ).tr(),
), ),
TextButton( ElevatedButton(
child: Text( child: const Text(
'backup_controller_page_background_battery_info_ok', 'backup_controller_page_background_battery_info_ok',
style: TextStyle(color: buttonTextColor), style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
).tr(), ).tr(),
onPressed: () { onPressed: () {
Navigator.of(context).pop(); Navigator.of(context).pop();
@@ -636,8 +636,8 @@ class BackupControllerPage extends HookConsumerWidget {
backupState.backupProgress == BackUpProgressEnum.inProgress backupState.backupProgress == BackUpProgressEnum.inProgress
? ElevatedButton( ? ElevatedButton(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
primary: Colors.red[300], foregroundColor: Colors.grey[50],
onPrimary: Colors.grey[50], backgroundColor: Colors.red[300],
// padding: const EdgeInsets.all(14), // padding: const EdgeInsets.all(14),
), ),
onPressed: () { onPressed: () {

View File

@@ -21,7 +21,7 @@ class ImmichSliverAppBar extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final BackUpState backupState = ref.watch(backupProvider); final BackUpState backupState = ref.watch(backupProvider);
bool isEnableAutoBackup = bool isEnableAutoBackup = backupState.backgroundBackup ||
ref.watch(authenticationProvider).deviceInfo.isAutoBackup; ref.watch(authenticationProvider).deviceInfo.isAutoBackup;
final ServerInfoState serverInfoState = ref.watch(serverInfoProvider); final ServerInfoState serverInfoState = ref.watch(serverInfoProvider);

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone description: Immich - selfhosted backup media file on mobile phone
publish_to: "none" publish_to: "none"
version: 1.28.0+38 version: 1.28.2+40
environment: environment:
sdk: ">=2.17.0 <3.0.0" sdk: ">=2.17.0 <3.0.0"

View File

@@ -11,7 +11,6 @@ import { GetAlbumsDto } from './dto/get-albums.dto';
import { RemoveAssetsDto } from './dto/remove-assets.dto'; import { RemoveAssetsDto } from './dto/remove-assets.dto';
import { UpdateAlbumDto } from './dto/update-album.dto'; import { UpdateAlbumDto } from './dto/update-album.dto';
import { AlbumCountResponseDto } from './response-dto/album-count-response.dto'; import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
import { AlbumResponseDto } from './response-dto/album-response.dto';
export interface IAlbumRepository { export interface IAlbumRepository {
create(ownerId: string, createAlbumDto: CreateAlbumDto): Promise<AlbumEntity>; create(ownerId: string, createAlbumDto: CreateAlbumDto): Promise<AlbumEntity>;
@@ -165,7 +164,7 @@ export class AlbumRepository implements IAlbumRepository {
} }
async getListByAssetId(userId: string, assetId: string): Promise<AlbumEntity[]> { async getListByAssetId(userId: string, assetId: string): Promise<AlbumEntity[]> {
let query = this.albumRepository.createQueryBuilder('album'); const query = this.albumRepository.createQueryBuilder('album');
const albums = await query const albums = await query
.where('album.ownerId = :ownerId', { ownerId: userId }) .where('album.ownerId = :ownerId', { ownerId: userId })
@@ -190,7 +189,7 @@ export class AlbumRepository implements IAlbumRepository {
} }
async get(albumId: string): Promise<AlbumEntity | undefined> { async get(albumId: string): Promise<AlbumEntity | undefined> {
let query = this.albumRepository.createQueryBuilder('album'); const query = this.albumRepository.createQueryBuilder('album');
const album = await query const album = await query
.where('album.id = :albumId', { albumId }) .where('album.id = :albumId', { albumId })

View File

@@ -11,7 +11,6 @@ import {
ParseUUIDPipe, ParseUUIDPipe,
Put, Put,
Query, Query,
Header,
} from '@nestjs/common'; } from '@nestjs/common';
import { ParseMeUUIDPipe } from '../validation/parse-me-uuid-pipe'; import { ParseMeUUIDPipe } from '../validation/parse-me-uuid-pipe';
import { AlbumService } from './album.service'; import { AlbumService } from './album.service';

View File

@@ -4,7 +4,6 @@ import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common'; import { BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common';
import { AlbumEntity } from '@app/database/entities/album.entity'; import { AlbumEntity } from '@app/database/entities/album.entity';
import { AlbumResponseDto } from './response-dto/album-response.dto'; import { AlbumResponseDto } from './response-dto/album-response.dto';
import { AssetAlbumEntity } from '@app/database/entities/asset-album.entity';
describe('Album service', () => { describe('Album service', () => {
let sut: AlbumService; let sut: AlbumService;

View File

@@ -1,4 +1,4 @@
import { IsNotEmpty, IsOptional } from 'class-validator'; import { IsOptional } from 'class-validator';
export class UpdateAlbumDto { export class UpdateAlbumDto {
@IsOptional() @IsOptional()

View File

@@ -15,7 +15,6 @@ import {
HttpCode, HttpCode,
BadRequestException, BadRequestException,
UploadedFile, UploadedFile,
Header,
} from '@nestjs/common'; } from '@nestjs/common';
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
import { AssetService } from './asset.service'; import { AssetService } from './asset.service';
@@ -34,7 +33,7 @@ import { IAssetUploadedJob } from '@app/job/index';
import { assetUploadedQueueName } from '@app/job/constants/queue-name.constant'; import { assetUploadedQueueName } from '@app/job/constants/queue-name.constant';
import { assetUploadedProcessorName } from '@app/job/constants/job-name.constant'; import { assetUploadedProcessorName } from '@app/job/constants/job-name.constant';
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto'; import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto'; import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto'; import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
import { AssetResponseDto } from './response-dto/asset-response.dto'; import { AssetResponseDto } from './response-dto/asset-response.dto';

View File

@@ -1,9 +1,8 @@
import { AssetRepository, IAssetRepository } from './asset-repository'; import { IAssetRepository } from './asset-repository';
import { AuthUserDto } from '../../decorators/auth-user.decorator'; import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { AssetService } from './asset.service'; import { AssetService } from './asset.service';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { AssetEntity, AssetType } from '@app/database/entities/asset.entity'; import { AssetEntity } from '@app/database/entities/asset.entity';
import { CreateAssetDto } from './dto/create-asset.dto';
describe('AssetService', () => { describe('AssetService', () => {
let sui: AssetService; let sui: AssetService;
@@ -15,39 +14,39 @@ describe('AssetService', () => {
email: 'auth@test.com', email: 'auth@test.com',
}); });
const _getCreateAssetDto = (): CreateAssetDto => { // const _getCreateAssetDto = (): CreateAssetDto => {
const createAssetDto = new CreateAssetDto(); // const createAssetDto = new CreateAssetDto();
createAssetDto.deviceAssetId = 'deviceAssetId'; // createAssetDto.deviceAssetId = 'deviceAssetId';
createAssetDto.deviceId = 'deviceId'; // createAssetDto.deviceId = 'deviceId';
createAssetDto.assetType = AssetType.OTHER; // createAssetDto.assetType = AssetType.OTHER;
createAssetDto.createdAt = '2022-06-19T23:41:36.910Z'; // createAssetDto.createdAt = '2022-06-19T23:41:36.910Z';
createAssetDto.modifiedAt = '2022-06-19T23:41:36.910Z'; // createAssetDto.modifiedAt = '2022-06-19T23:41:36.910Z';
createAssetDto.isFavorite = false; // createAssetDto.isFavorite = false;
createAssetDto.duration = '0:00:00.000000'; // createAssetDto.duration = '0:00:00.000000';
return createAssetDto; // return createAssetDto;
}; // };
const _getAsset = () => { // const _getAsset = () => {
const assetEntity = new AssetEntity(); // const assetEntity = new AssetEntity();
assetEntity.id = 'e8edabfd-7d8a-45d0-9d61-7c7ca60f2c67'; // assetEntity.id = 'e8edabfd-7d8a-45d0-9d61-7c7ca60f2c67';
assetEntity.userId = '3ea54709-e168-42b7-90b0-a0dfe8a7ecbd'; // assetEntity.userId = '3ea54709-e168-42b7-90b0-a0dfe8a7ecbd';
assetEntity.deviceAssetId = '4967046344801'; // assetEntity.deviceAssetId = '4967046344801';
assetEntity.deviceId = '116766fd-2ef2-52dc-a3ef-149988997291'; // assetEntity.deviceId = '116766fd-2ef2-52dc-a3ef-149988997291';
assetEntity.type = AssetType.VIDEO; // assetEntity.type = AssetType.VIDEO;
assetEntity.originalPath = // assetEntity.originalPath =
'upload/3ea54709-e168-42b7-90b0-a0dfe8a7ecbd/original/116766fd-2ef2-52dc-a3ef-149988997291/51c97f95-244f-462d-bdf0-e1dc19913516.jpg'; // 'upload/3ea54709-e168-42b7-90b0-a0dfe8a7ecbd/original/116766fd-2ef2-52dc-a3ef-149988997291/51c97f95-244f-462d-bdf0-e1dc19913516.jpg';
assetEntity.resizePath = ''; // assetEntity.resizePath = '';
assetEntity.createdAt = '2022-06-19T23:41:36.910Z'; // assetEntity.createdAt = '2022-06-19T23:41:36.910Z';
assetEntity.modifiedAt = '2022-06-19T23:41:36.910Z'; // assetEntity.modifiedAt = '2022-06-19T23:41:36.910Z';
assetEntity.isFavorite = false; // assetEntity.isFavorite = false;
assetEntity.mimeType = 'image/jpeg'; // assetEntity.mimeType = 'image/jpeg';
assetEntity.webpPath = ''; // assetEntity.webpPath = '';
assetEntity.encodedVideoPath = ''; // assetEntity.encodedVideoPath = '';
assetEntity.duration = '0:00:00.000000'; // assetEntity.duration = '0:00:00.000000';
return assetEntity; // return assetEntity;
}; // };
beforeAll(() => { beforeAll(() => {
assetRepositoryMock = { assetRepositoryMock = {

View File

@@ -1,6 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer'; import { IsOptional } from 'class-validator';
import { IsBoolean, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
export enum GetAssetThumbnailFormatEnum { export enum GetAssetThumbnailFormatEnum {
JPEG = 'JPEG', JPEG = 'JPEG',

View File

@@ -1,6 +1,6 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer'; import { Transform } from 'class-transformer';
import { IsBoolean, IsBooleanString, IsNotEmpty, IsOptional } from 'class-validator'; import { IsBoolean, IsNotEmpty, IsOptional } from 'class-validator';
export class ServeFileDto { export class ServeFileDto {
@IsNotEmpty() @IsNotEmpty()

View File

@@ -1,12 +1,5 @@
import { Body, Controller, Post, Res, UseGuards, ValidationPipe } from '@nestjs/common'; import { Body, Controller, Post, Res, UseGuards, ValidationPipe } from '@nestjs/common';
import { import { ApiBadRequestResponse, ApiBearerAuth, ApiTags } from '@nestjs/swagger';
ApiBadRequestResponse,
ApiBearerAuth,
ApiBody,
ApiCreatedResponse,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator'; import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';

View File

@@ -1,5 +1,5 @@
import { UserEntity } from '@app/database/entities/user.entity'; import { UserEntity } from '@app/database/entities/user.entity';
import { ApiProperty, ApiResponseProperty } from '@nestjs/swagger'; import { ApiResponseProperty } from '@nestjs/swagger';
export class LoginResponseDto { export class LoginResponseDto {
@ApiResponseProperty() @ApiResponseProperty()

View File

@@ -1,8 +1,6 @@
import { DeviceType } from '@app/database/entities/device-info.entity'; import { DeviceType } from '@app/database/entities/device-info.entity';
import { PartialType } from '@nestjs/mapped-types';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsOptional } from 'class-validator'; import { IsNotEmpty, IsOptional } from 'class-validator';
import { CreateDeviceInfoDto } from './create-device-info.dto';
export class UpdateDeviceInfoDto { export class UpdateDeviceInfoDto {
@IsNotEmpty() @IsNotEmpty()

View File

@@ -1,13 +1,10 @@
import { Controller, Get, UseGuards } from '@nestjs/common'; import { Controller, Get } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
import { ServerInfoService } from './server-info.service'; import { ServerInfoService } from './server-info.service';
import { serverVersion } from '../../constants/server_version.constant'; import { serverVersion } from '../../constants/server_version.constant';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { ServerPingResponse } from './response-dto/server-ping-response.dto'; import { ServerPingResponse } from './response-dto/server-ping-response.dto';
import { ServerVersionReponseDto } from './response-dto/server-version-response.dto'; import { ServerVersionReponseDto } from './response-dto/server-version-response.dto';
import { ServerInfoResponseDto } from './response-dto/server-info-response.dto'; import { ServerInfoResponseDto } from './response-dto/server-info-response.dto';
import { DataSource } from 'typeorm';
@ApiTags('Server Info') @ApiTags('Server Info')
@Controller('server-info') @Controller('server-info')

View File

@@ -12,7 +12,6 @@ import {
UploadedFile, UploadedFile,
Response, Response,
Request, Request,
StreamableFile,
ParseBoolPipe, ParseBoolPipe,
} from '@nestjs/common'; } from '@nestjs/common';
import { UserService } from './user.service'; import { UserService } from './user.service';

View File

@@ -15,7 +15,6 @@ import { AppController } from './app.controller';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
import { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.module'; import { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.module';
import { DatabaseModule } from '@app/database'; import { DatabaseModule } from '@app/database';
import { AppLoggerMiddleware } from './middlewares/app-logger.middleware';
@Module({ @Module({
imports: [ imports: [

View File

@@ -11,6 +11,6 @@ export interface IServerVersion {
export const serverVersion: IServerVersion = { export const serverVersion: IServerVersion = {
major: 1, major: 1,
minor: 28, minor: 28,
patch: 0, patch: 2,
build: 0, build: 40,
}; };

View File

@@ -1,4 +1,3 @@
import { dataSource } from '@app/database/config/database.config';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express'; import { NestExpressApplication } from '@nestjs/platform-express';
@@ -7,6 +6,7 @@ import cookieParser from 'cookie-parser';
import { writeFileSync } from 'fs'; import { writeFileSync } from 'fs';
import path from 'path'; import path from 'path';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { serverVersion } from './constants/server_version.constant';
import { RedisIoAdapter } from './middlewares/redis-io.adapter.middleware'; import { RedisIoAdapter } from './middlewares/redis-io.adapter.middleware';
async function bootstrap() { async function bootstrap() {
@@ -53,11 +53,17 @@ async function bootstrap() {
// Generate API Documentation only in development mode // Generate API Documentation only in development mode
const outputPath = path.resolve(process.cwd(), 'immich-openapi-specs.json'); const outputPath = path.resolve(process.cwd(), 'immich-openapi-specs.json');
writeFileSync(outputPath, JSON.stringify(apiDocument), { encoding: 'utf8' }); writeFileSync(outputPath, JSON.stringify(apiDocument), { encoding: 'utf8' });
Logger.log('Running Immich Server in DEVELOPMENT environment', 'ImmichServer'); Logger.log(
`Running Immich Server in DEVELOPMENT environment - version ${serverVersion.major}.${serverVersion.minor}.${serverVersion.patch}`,
'ImmichServer',
);
} }
if (process.env.NODE_ENV == 'production') { if (process.env.NODE_ENV == 'production') {
Logger.log('Running Immich Server in PRODUCTION environment', 'ImmichServer'); Logger.log(
`Running Immich Server in PRODUCTION environment - version ${serverVersion.major}.${serverVersion.minor}.${serverVersion.patch}`,
'ImmichServer',
);
} }
}); });
} }

View File

@@ -2,7 +2,6 @@ import { InjectQueue } from '@nestjs/bull/dist/decorators';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Queue } from 'bull'; import { Queue } from 'bull';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { AssetEntity } from '@app/database/entities/asset.entity';
import { AssetResponseDto } from '../../api-v1/asset/response-dto/asset-response.dto'; import { AssetResponseDto } from '../../api-v1/asset/response-dto/asset-response.dto';
@Injectable() @Injectable()

View File

@@ -1,5 +1,6 @@
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { serverVersion } from 'apps/immich/src/constants/server_version.constant';
import { RedisIoAdapter } from '../../immich/src/middlewares/redis-io.adapter.middleware'; import { RedisIoAdapter } from '../../immich/src/middlewares/redis-io.adapter.middleware';
import { MicroservicesModule } from './microservices.module'; import { MicroservicesModule } from './microservices.module';
@@ -10,11 +11,17 @@ async function bootstrap() {
await app.listen(3002, () => { await app.listen(3002, () => {
if (process.env.NODE_ENV == 'development') { if (process.env.NODE_ENV == 'development') {
Logger.log('Running Immich Microservices in DEVELOPMENT environment', 'ImmichMicroservice'); Logger.log(
`Running Immich Microservices in DEVELOPMENT environment - version ${serverVersion.major}.${serverVersion.minor}.${serverVersion.patch}`,
'ImmichMicroservice',
);
} }
if (process.env.NODE_ENV == 'production') { if (process.env.NODE_ENV == 'production') {
Logger.log('Running Immich Microservices in PRODUCTION environment', 'ImmichMicroservice'); Logger.log(
`Running Immich Microservices in PRODUCTION environment - version ${serverVersion.major}.${serverVersion.minor}.${serverVersion.patch}`,
'ImmichMicroservice',
);
} }
}); });
} }

View File

@@ -5,7 +5,7 @@ import { Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import fs from 'node:fs'; import fs from 'node:fs';
import { IsNull, Repository } from 'typeorm'; import { FindOptionsWhere, IsNull, MoreThan, QueryFailedError, Repository } from 'typeorm';
// TODO: just temporary task to generate previous uploaded assets. // TODO: just temporary task to generate previous uploaded assets.
@Processor(generateChecksumQueueName) @Processor(generateChecksumQueueName)
@@ -17,15 +17,23 @@ export class GenerateChecksumProcessor {
@Process() @Process()
async generateChecksum() { async generateChecksum() {
const pageSize = 200;
let hasNext = true; let hasNext = true;
let pageSize = 200; let lastErrAssetId: string | undefined = undefined;
while (hasNext) { while (hasNext) {
const whereStat: FindOptionsWhere<AssetEntity> = {
checksum: IsNull(),
};
if (lastErrAssetId) {
whereStat.id = MoreThan(lastErrAssetId);
}
const assets = await this.assetRepository.find({ const assets = await this.assetRepository.find({
where: { where: whereStat,
checksum: IsNull()
},
take: pageSize, take: pageSize,
order: { id: 'ASC' }
}); });
if (!assets?.length) { if (!assets?.length) {
@@ -35,15 +43,24 @@ export class GenerateChecksumProcessor {
try { try {
await this.generateAssetChecksum(asset); await this.generateAssetChecksum(asset);
} catch (err: any) { } catch (err: any) {
Logger.error(`Error generate checksum ${err}`); lastErrAssetId = asset.id;
if (err instanceof QueryFailedError && (err as any).constraint === 'UQ_userid_checksum') {
Logger.error(`${asset.originalPath} duplicated`);
} else {
Logger.error(`checksum generation ${err}`);
}
} }
} }
// break when reach to the last page
if (assets.length < pageSize) { if (assets.length < pageSize) {
hasNext = false; hasNext = false;
} }
} }
} }
Logger.log(`checksum generation done!`);
} }
private async generateAssetChecksum(asset: AssetEntity) { private async generateAssetChecksum(asset: AssetEntity) {

View File

@@ -21,7 +21,6 @@ import axios from 'axios';
import { Job } from 'bull'; import { Job } from 'bull';
import exifr from 'exifr'; import exifr from 'exifr';
import ffmpeg from 'fluent-ffmpeg'; import ffmpeg from 'fluent-ffmpeg';
import { readFile } from 'fs/promises';
import path from 'path'; import path from 'path';
import sharp from 'sharp'; import sharp from 'sharp';
import { Repository } from 'typeorm/repository/Repository'; import { Repository } from 'typeorm/repository/Repository';
@@ -330,7 +329,7 @@ export class MetadataExtractionProcessor {
} }
if (stream.r_frame_rate) { if (stream.r_frame_rate) {
let fpsParts = stream.r_frame_rate.split('/'); const fpsParts = stream.r_frame_rate.split('/');
if (fpsParts.length === 2) { if (fpsParts.length === 2) {
newExif.fps = Math.round(parseInt(fpsParts[0]) / parseInt(fpsParts[1])); newExif.fps = Math.round(parseInt(fpsParts[0]) / parseInt(fpsParts[1]));

View File

@@ -16,5 +16,8 @@ module.exports = {
browser: true, browser: true,
es2017: true, es2017: true,
node: true node: true
},
globals: {
NodeJS: true
} }
}; };

View File

@@ -6,6 +6,9 @@ node_modules
.env .env
.env.* .env.*
!.env.example !.env.example
src/api/open-api
*.md
*.json
# Ignore files for PNPM, NPM and YARN # Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml pnpm-lock.yaml

View File

@@ -178,14 +178,17 @@ export default {
// A map from regular expressions to paths to transformers // A map from regular expressions to paths to transformers
transform: { transform: {
'\\.[jt]sx?$': 'babel-jest' '\\.[jt]sx?$': 'babel-jest',
} '^.+\\.svelte$': [
'svelte-jester',
{
preprocess: true
}
]
},
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
// transformIgnorePatterns: [ transformIgnorePatterns: ['/node_modules/(?!svelte-material-icons).*/', '\\.pnp\\.[^\\/]+$']
// "/node_modules/",
// "\\.pnp\\.[^\\/]+$"
// ],
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
// unmockedModulePathPatterns: undefined, // unmockedModulePathPatterns: undefined,

338
web/package-lock.json generated
View File

@@ -25,6 +25,8 @@
"@sveltejs/adapter-auto": "next", "@sveltejs/adapter-auto": "next",
"@sveltejs/adapter-node": "next", "@sveltejs/adapter-node": "next",
"@sveltejs/kit": "next", "@sveltejs/kit": "next",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/svelte": "^3.2.1",
"@types/bcrypt": "^5.0.0", "@types/bcrypt": "^5.0.0",
"@types/cookie": "^0.4.1", "@types/cookie": "^0.4.1",
"@types/fluent-ffmpeg": "^2.1.20", "@types/fluent-ffmpeg": "^2.1.20",
@@ -48,13 +50,19 @@
"svelte": "^3.44.0", "svelte": "^3.44.0",
"svelte-check": "^2.7.1", "svelte-check": "^2.7.1",
"svelte-jester": "^2.3.2", "svelte-jester": "^2.3.2",
"svelte-preprocess": "^4.10.6", "svelte-preprocess": "^4.10.7",
"tailwindcss": "^3.0.24", "tailwindcss": "^3.0.24",
"tslib": "^2.3.1", "tslib": "^2.3.1",
"typescript": "^4.7.4", "typescript": "^4.7.4",
"vite": "^3.0.0" "vite": "^3.0.0"
} }
}, },
"node_modules/@adobe/css-tools": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.0.1.tgz",
"integrity": "sha512-+u76oB43nOHrF4DDWRLWDCtci7f3QJoEBigemIdIeTi1ODqjx6Tad9NCVnPRwewWlKkVab5PlK8DCtPTyX7S8g==",
"dev": true
},
"node_modules/@ampproject/remapping": { "node_modules/@ampproject/remapping": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz",
@@ -2626,6 +2634,107 @@
} }
} }
}, },
"node_modules/@testing-library/dom": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.17.1.tgz",
"integrity": "sha512-KnH2MnJUzmFNPW6RIKfd+zf2Wue8mEKX0M3cpX6aKl5ZXrJM1/c/Pc8c2xDNYQCnJO48Sm5ITbMXgqTr3h4jxQ==",
"dev": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
"@types/aria-query": "^4.2.0",
"aria-query": "^5.0.0",
"chalk": "^4.1.0",
"dom-accessibility-api": "^0.5.9",
"lz-string": "^1.4.4",
"pretty-format": "^27.0.2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@testing-library/dom/node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/@testing-library/dom/node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
"react-is": "^17.0.1"
},
"engines": {
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
}
},
"node_modules/@testing-library/dom/node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true
},
"node_modules/@testing-library/jest-dom": {
"version": "5.16.5",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.16.5.tgz",
"integrity": "sha512-N5ixQ2qKpi5OLYfwQmUb/5mSV9LneAcaUfp32pn4yCnpb8r/Yz0pXFPck21dIicKmi+ta5WRAknkZCfA8refMA==",
"dev": true,
"dependencies": {
"@adobe/css-tools": "^4.0.1",
"@babel/runtime": "^7.9.2",
"@types/testing-library__jest-dom": "^5.9.1",
"aria-query": "^5.0.0",
"chalk": "^3.0.0",
"css.escape": "^1.5.1",
"dom-accessibility-api": "^0.5.6",
"lodash": "^4.17.15",
"redent": "^3.0.0"
},
"engines": {
"node": ">=8",
"npm": ">=6",
"yarn": ">=1"
}
},
"node_modules/@testing-library/jest-dom/node_modules/chalk": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz",
"integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==",
"dev": true,
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@testing-library/svelte": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-3.2.1.tgz",
"integrity": "sha512-qP5nMAx78zt+a3y9Sws9BNQYP30cOQ/LXDYuAj7wNtw86b7AtB7TFAz6/Av9hFsW3IJHPBBIGff6utVNyq+F1g==",
"dev": true,
"dependencies": {
"@testing-library/dom": "^8.1.0"
},
"engines": {
"node": ">= 10"
},
"peerDependencies": {
"svelte": "3.x"
}
},
"node_modules/@tootallnate/once": { "node_modules/@tootallnate/once": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
@@ -2635,6 +2744,12 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/@types/aria-query": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-4.2.2.tgz",
"integrity": "sha512-HnYpAE1Y6kRyKM/XkEuiRQhTHvkzMBurTHnpFLYLBGPIylZNPs9jJcuOOYWxPLJCSEtmZT0Y8rHDokKN7rRTig==",
"dev": true
},
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.1.19", "version": "7.1.19",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.19.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.19.tgz",
@@ -2739,6 +2854,16 @@
"@types/istanbul-lib-report": "*" "@types/istanbul-lib-report": "*"
} }
}, },
"node_modules/@types/jest": {
"version": "29.0.0",
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.0.0.tgz",
"integrity": "sha512-X6Zjz3WO4cT39Gkl0lZ2baFRaEMqJl5NC1OjElkwtNzAlbkr2K/WJXkBkH5VP0zx4Hgsd2TZYdOEfvp2Dxia+Q==",
"dev": true,
"dependencies": {
"expect": "^29.0.0",
"pretty-format": "^29.0.0"
}
},
"node_modules/@types/jsdom": { "node_modules/@types/jsdom": {
"version": "20.0.0", "version": "20.0.0",
"resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.0.tgz",
@@ -2823,6 +2948,15 @@
"integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==",
"dev": true "dev": true
}, },
"node_modules/@types/testing-library__jest-dom": {
"version": "5.14.5",
"resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.5.tgz",
"integrity": "sha512-SBwbxYoyPIvxHbeHxTZX2Pe/74F/tX2/D3mMvzabdeJ25bBojfW0TyB8BHrbq/9zaaKICJZjLP+8r6AeZMFCuQ==",
"dev": true,
"dependencies": {
"@types/jest": "*"
}
},
"node_modules/@types/tough-cookie": { "node_modules/@types/tough-cookie": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.2.tgz",
@@ -3260,6 +3394,15 @@
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true "dev": true
}, },
"node_modules/aria-query": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.0.2.tgz",
"integrity": "sha512-eigU3vhqSO+Z8BKDnVLN/ompjhf3pYzecKXz8+whRy+9gZu8n1TCGfwzQUUPnqdHl9ax1Hr9031orZ+UOEYr7Q==",
"dev": true,
"engines": {
"node": ">=6.0"
}
},
"node_modules/array-union": { "node_modules/array-union": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
@@ -3866,6 +4009,12 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/css.escape": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
"integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
"dev": true
},
"node_modules/cssesc": { "node_modules/cssesc": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -4133,6 +4282,12 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/dom-accessibility-api": {
"version": "0.5.14",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.14.tgz",
"integrity": "sha512-NMt+m9zFMPZe0JcY9gN224Qvk6qLIdqex29clBvc/y75ZBX9YA9wNK3frsYvu2DI1xcCIwxwnX+TlsJ2DSOADg==",
"dev": true
},
"node_modules/domexception": { "node_modules/domexception": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz",
@@ -5601,6 +5756,15 @@
"node": ">=0.8.19" "node": ">=0.8.19"
} }
}, },
"node_modules/indent-string": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
"integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
"dev": true,
"engines": {
"node": ">=8"
}
},
"node_modules/inflight": { "node_modules/inflight": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
@@ -6660,6 +6824,15 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/lz-string": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz",
"integrity": "sha512-0ckx7ZHRPqb0oUm8zNr+90mtf9DQB60H1wMCjBtfi62Kl3a7JbHob6gA2bC+xRvZoOL+1hzUK8jeuEIQE8svEQ==",
"dev": true,
"bin": {
"lz-string": "bin/bin.js"
}
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.26.2", "version": "0.26.2",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.26.2.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.26.2.tgz",
@@ -7621,6 +7794,19 @@
"node": ">=8.10.0" "node": ">=8.10.0"
} }
}, },
"node_modules/redent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
"integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
"dev": true,
"dependencies": {
"indent-string": "^4.0.0",
"strip-indent": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/regenerate": { "node_modules/regenerate": {
"version": "1.4.2", "version": "1.4.2",
"resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz",
@@ -9091,6 +9277,12 @@
} }
}, },
"dependencies": { "dependencies": {
"@adobe/css-tools": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.0.1.tgz",
"integrity": "sha512-+u76oB43nOHrF4DDWRLWDCtci7f3QJoEBigemIdIeTi1ODqjx6Tad9NCVnPRwewWlKkVab5PlK8DCtPTyX7S8g==",
"dev": true
},
"@ampproject/remapping": { "@ampproject/remapping": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz",
@@ -10964,12 +11156,97 @@
"svelte-hmr": "^0.14.12" "svelte-hmr": "^0.14.12"
} }
}, },
"@testing-library/dom": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.17.1.tgz",
"integrity": "sha512-KnH2MnJUzmFNPW6RIKfd+zf2Wue8mEKX0M3cpX6aKl5ZXrJM1/c/Pc8c2xDNYQCnJO48Sm5ITbMXgqTr3h4jxQ==",
"dev": true,
"requires": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
"@types/aria-query": "^4.2.0",
"aria-query": "^5.0.0",
"chalk": "^4.1.0",
"dom-accessibility-api": "^0.5.9",
"lz-string": "^1.4.4",
"pretty-format": "^27.0.2"
},
"dependencies": {
"ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true
},
"pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"requires": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
"react-is": "^17.0.1"
}
},
"react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true
}
}
},
"@testing-library/jest-dom": {
"version": "5.16.5",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.16.5.tgz",
"integrity": "sha512-N5ixQ2qKpi5OLYfwQmUb/5mSV9LneAcaUfp32pn4yCnpb8r/Yz0pXFPck21dIicKmi+ta5WRAknkZCfA8refMA==",
"dev": true,
"requires": {
"@adobe/css-tools": "^4.0.1",
"@babel/runtime": "^7.9.2",
"@types/testing-library__jest-dom": "^5.9.1",
"aria-query": "^5.0.0",
"chalk": "^3.0.0",
"css.escape": "^1.5.1",
"dom-accessibility-api": "^0.5.6",
"lodash": "^4.17.15",
"redent": "^3.0.0"
},
"dependencies": {
"chalk": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz",
"integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==",
"dev": true,
"requires": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
}
}
}
},
"@testing-library/svelte": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-3.2.1.tgz",
"integrity": "sha512-qP5nMAx78zt+a3y9Sws9BNQYP30cOQ/LXDYuAj7wNtw86b7AtB7TFAz6/Av9hFsW3IJHPBBIGff6utVNyq+F1g==",
"dev": true,
"requires": {
"@testing-library/dom": "^8.1.0"
}
},
"@tootallnate/once": { "@tootallnate/once": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
"integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==",
"dev": true "dev": true
}, },
"@types/aria-query": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-4.2.2.tgz",
"integrity": "sha512-HnYpAE1Y6kRyKM/XkEuiRQhTHvkzMBurTHnpFLYLBGPIylZNPs9jJcuOOYWxPLJCSEtmZT0Y8rHDokKN7rRTig==",
"dev": true
},
"@types/babel__core": { "@types/babel__core": {
"version": "7.1.19", "version": "7.1.19",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.19.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.19.tgz",
@@ -11074,6 +11351,16 @@
"@types/istanbul-lib-report": "*" "@types/istanbul-lib-report": "*"
} }
}, },
"@types/jest": {
"version": "29.0.0",
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.0.0.tgz",
"integrity": "sha512-X6Zjz3WO4cT39Gkl0lZ2baFRaEMqJl5NC1OjElkwtNzAlbkr2K/WJXkBkH5VP0zx4Hgsd2TZYdOEfvp2Dxia+Q==",
"dev": true,
"requires": {
"expect": "^29.0.0",
"pretty-format": "^29.0.0"
}
},
"@types/jsdom": { "@types/jsdom": {
"version": "20.0.0", "version": "20.0.0",
"resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.0.tgz",
@@ -11157,6 +11444,15 @@
"integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==",
"dev": true "dev": true
}, },
"@types/testing-library__jest-dom": {
"version": "5.14.5",
"resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.5.tgz",
"integrity": "sha512-SBwbxYoyPIvxHbeHxTZX2Pe/74F/tX2/D3mMvzabdeJ25bBojfW0TyB8BHrbq/9zaaKICJZjLP+8r6AeZMFCuQ==",
"dev": true,
"requires": {
"@types/jest": "*"
}
},
"@types/tough-cookie": { "@types/tough-cookie": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.2.tgz",
@@ -11451,6 +11747,12 @@
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true "dev": true
}, },
"aria-query": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.0.2.tgz",
"integrity": "sha512-eigU3vhqSO+Z8BKDnVLN/ompjhf3pYzecKXz8+whRy+9gZu8n1TCGfwzQUUPnqdHl9ax1Hr9031orZ+UOEYr7Q==",
"dev": true
},
"array-union": { "array-union": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
@@ -11908,6 +12210,12 @@
"which": "^2.0.1" "which": "^2.0.1"
} }
}, },
"css.escape": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
"integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
"dev": true
},
"cssesc": { "cssesc": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -12111,6 +12419,12 @@
"esutils": "^2.0.2" "esutils": "^2.0.2"
} }
}, },
"dom-accessibility-api": {
"version": "0.5.14",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.14.tgz",
"integrity": "sha512-NMt+m9zFMPZe0JcY9gN224Qvk6qLIdqex29clBvc/y75ZBX9YA9wNK3frsYvu2DI1xcCIwxwnX+TlsJ2DSOADg==",
"dev": true
},
"domexception": { "domexception": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz",
@@ -13102,6 +13416,12 @@
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
"dev": true "dev": true
}, },
"indent-string": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
"integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
"dev": true
},
"inflight": { "inflight": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
@@ -13909,6 +14229,12 @@
"yallist": "^4.0.0" "yallist": "^4.0.0"
} }
}, },
"lz-string": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz",
"integrity": "sha512-0ckx7ZHRPqb0oUm8zNr+90mtf9DQB60H1wMCjBtfi62Kl3a7JbHob6gA2bC+xRvZoOL+1hzUK8jeuEIQE8svEQ==",
"dev": true
},
"magic-string": { "magic-string": {
"version": "0.26.2", "version": "0.26.2",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.26.2.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.26.2.tgz",
@@ -14562,6 +14888,16 @@
"picomatch": "^2.2.1" "picomatch": "^2.2.1"
} }
}, },
"redent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
"integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
"dev": true,
"requires": {
"indent-string": "^4.0.0",
"strip-indent": "^3.0.0"
}
},
"regenerate": { "regenerate": {
"version": "1.4.2", "version": "1.4.2",
"resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz",

View File

@@ -6,10 +6,14 @@
"build": "vite build", "build": "vite build",
"package": "svelte-kit package", "package": "svelte-kit package",
"preview": "vite preview", "preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json", "check": "svelte-check --tsconfig ./tsconfig.json --fail-on-warnings",
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "npm run check -- --watch",
"lint": "prettier --check --plugin-search-dir=. . && eslint .", "check:code": "npm run format && npm run lint && npm run check",
"format": "prettier --write --plugin-search-dir=. .", "check:all": "npm run check:code && npm test",
"lint": "eslint . --max-warnings 0",
"lint:fix": "npm run lint -- --fix",
"format": "prettier --check --plugin-search-dir=. .",
"format:fix": "prettier --write --plugin-search-dir=. .",
"test": "jest", "test": "jest",
"test:watch": "npm test -- --watch" "test:watch": "npm test -- --watch"
}, },
@@ -20,6 +24,8 @@
"@sveltejs/adapter-auto": "next", "@sveltejs/adapter-auto": "next",
"@sveltejs/adapter-node": "next", "@sveltejs/adapter-node": "next",
"@sveltejs/kit": "next", "@sveltejs/kit": "next",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/svelte": "^3.2.1",
"@types/bcrypt": "^5.0.0", "@types/bcrypt": "^5.0.0",
"@types/cookie": "^0.4.1", "@types/cookie": "^0.4.1",
"@types/fluent-ffmpeg": "^2.1.20", "@types/fluent-ffmpeg": "^2.1.20",
@@ -43,7 +49,7 @@
"svelte": "^3.44.0", "svelte": "^3.44.0",
"svelte-check": "^2.7.1", "svelte-check": "^2.7.1",
"svelte-jester": "^2.3.2", "svelte-jester": "^2.3.2",
"svelte-preprocess": "^4.10.6", "svelte-preprocess": "^4.10.7",
"tailwindcss": "^3.0.24", "tailwindcss": "^3.0.24",
"tslib": "^2.3.1", "tslib": "^2.3.1",
"typescript": "^4.7.4", "typescript": "^4.7.4",

View File

@@ -1,5 +1,4 @@
import { AssetCountByTimeGroupResponseDto } from '@api'; const _basePath = '/api';
let _basePath = '/api';
export function getFileUrl(aid: string, did: string, isThumb?: boolean, isWeb?: boolean) { export function getFileUrl(aid: string, did: string, isThumb?: boolean, isWeb?: boolean) {
const urlObj = new URL(`${window.location.origin}${_basePath}/asset/file`); const urlObj = new URL(`${window.location.origin}${_basePath}/asset/file`);

View File

@@ -6,86 +6,93 @@
@tailwind utilities; @tailwind utilities;
:root { :root {
font-family: 'Work Sans', sans-serif; font-family: 'Work Sans', sans-serif;
/* --immich-icon-button-hover-color: #d3d3d3; */ /* --immich-icon-button-hover-color: #d3d3d3; */
} }
html { html {
height: 100%; height: 100%;
width: 100%; width: 100%;
} }
html::-webkit-scrollbar { html::-webkit-scrollbar {
width: 8px; width: 8px;
} }
/* Track */ /* Track */
html::-webkit-scrollbar-track { html::-webkit-scrollbar-track {
background: #f1f1f1; background: #f1f1f1;
border-radius: 16px; border-radius: 16px;
} }
/* Handle */ /* Handle */
html::-webkit-scrollbar-thumb { html::-webkit-scrollbar-thumb {
background: rgba(85, 86, 87, 0.408); background: rgba(85, 86, 87, 0.408);
border-radius: 16px; border-radius: 16px;
} }
/* Handle on hover */ /* Handle on hover */
html::-webkit-scrollbar-thumb:hover { html::-webkit-scrollbar-thumb:hover {
background: #4250afad; background: #4250afad;
border-radius: 16px; border-radius: 16px;
} }
body { body {
/* min-height: 100vh; */ /* min-height: 100vh; */
margin: 0; margin: 0;
background-color: #f6f8fe; background-color: #f6f8fe;
color: #5f6368; color: #5f6368;
} }
input:focus-visible { input:focus-visible {
outline-offset: 0px !important; outline-offset: 0px !important;
outline: none !important; outline: none !important;
} }
@layer utilities { @layer utilities {
.immich-form-input { .immich-form-input {
@apply bg-slate-100 p-2 rounded-md focus:border-immich-primary text-sm ; @apply bg-slate-100 p-2 rounded-md focus:border-immich-primary text-sm;
} }
.immich-form-label { .immich-form-label {
@apply font-medium text-sm text-gray-500; @apply font-medium text-sm text-gray-500;
} }
.immich-btn-primary { .immich-btn-primary {
@apply bg-immich-primary text-gray-100 border rounded-xl py-2 px-4 transition-all duration-150 hover:bg-immich-primary hover:shadow-lg text-sm font-medium; @apply bg-immich-primary text-gray-100 border rounded-xl py-2 px-4 transition-all duration-150 hover:bg-immich-primary hover:shadow-lg text-sm font-medium;
} }
.immich-text-button { .immich-text-button {
@apply flex place-items-center place-content-center gap-2 hover:bg-immich-primary/5 p-2 rounded-lg font-medium; @apply flex place-items-center place-content-center gap-2 hover:bg-immich-primary/5 p-2 rounded-lg font-medium;
} }
/* width */ /* width */
.immich-scrollbar::-webkit-scrollbar { .immich-scrollbar::-webkit-scrollbar {
width: 8px; width: 8px;
} }
/* Track */ /* Track */
.immich-scrollbar::-webkit-scrollbar-track { .immich-scrollbar::-webkit-scrollbar-track {
background: #f1f1f1; background: #f1f1f1;
border-radius: 16px; border-radius: 16px;
} }
/* Handle */ /* Handle */
.immich-scrollbar::-webkit-scrollbar-thumb { .immich-scrollbar::-webkit-scrollbar-thumb {
background: rgba(85, 86, 87, 0.408); background: rgba(85, 86, 87, 0.408);
border-radius: 16px; border-radius: 16px;
} }
/* Handle on hover */ /* Handle on hover */
.immich-scrollbar::-webkit-scrollbar-thumb:hover { .immich-scrollbar::-webkit-scrollbar-thumb:hover {
background: #4250afad; background: #4250afad;
border-radius: 16px; border-radius: 16px;
} }
/* Hidden scrollbar */
/* width */
.scrollbar-hidden::-webkit-scrollbar {
display: none;
scrollbar-width: none;
}
} }

View File

@@ -0,0 +1,8 @@
const createObjectURLMock = jest.fn();
Object.defineProperty(URL, 'createObjectURL', {
writable: true,
value: createObjectURLMock
});
export { createObjectURLMock };

View File

@@ -0,0 +1,144 @@
import { jest, describe, it } from '@jest/globals';
import { render, RenderResult, waitFor, fireEvent } from '@testing-library/svelte';
import { createObjectURLMock } from '$lib/__mocks__/jsdom-url.mock';
import { api, ThumbnailFormat } from '@api';
import { albumFactory } from '@test-data';
import AlbumCard from '../album-card.svelte';
import '@testing-library/jest-dom';
jest.mock('@api');
const apiMock: jest.MockedObject<typeof api> = api as jest.MockedObject<typeof api>;
describe('AlbumCard component', () => {
let sut: RenderResult<AlbumCard>;
it.each([
{
album: albumFactory.build({ albumThumbnailAssetId: null, shared: false, assetCount: 0 }),
count: 0,
shared: false
},
{
album: albumFactory.build({ albumThumbnailAssetId: null, shared: true, assetCount: 0 }),
count: 0,
shared: true
},
{
album: albumFactory.build({ albumThumbnailAssetId: null, shared: false, assetCount: 5 }),
count: 5,
shared: false
},
{
album: albumFactory.build({ albumThumbnailAssetId: null, shared: true, assetCount: 2 }),
count: 2,
shared: true
}
])(
'shows album data without thumbnail with count $count - shared: $shared',
async ({ album, count, shared }) => {
sut = render(AlbumCard, { album });
const albumImgElement = sut.getByTestId('album-image');
const albumNameElement = sut.getByTestId('album-name');
const albumDetailsElement = sut.getByTestId('album-details');
const detailsText = `${count} items` + (shared ? ' . Shared' : '');
// TODO: is this a bug?
expect(albumImgElement).toHaveAttribute('src', '/api/asset/thumbnail/null?format=WEBP');
expect(albumImgElement).toHaveAttribute('alt', album.id);
await waitFor(() => expect(albumImgElement).toHaveAttribute('src', 'no-thumbnail.png'));
expect(albumImgElement).toHaveAttribute('alt', album.id);
expect(apiMock.assetApi.getAssetThumbnail).not.toHaveBeenCalled();
expect(albumNameElement).toHaveTextContent(album.albumName);
expect(albumDetailsElement).toHaveTextContent(new RegExp(detailsText));
}
);
it('shows album data and and loads the thumbnail image when available', async () => {
const thumbnailBlob = new Blob();
const thumbnailUrl = 'blob:thumbnailUrlOne';
apiMock.assetApi.getAssetThumbnail.mockResolvedValue({
data: thumbnailBlob,
config: {},
headers: {},
status: 200,
statusText: ''
});
createObjectURLMock.mockReturnValueOnce(thumbnailUrl);
const album = albumFactory.build({
albumThumbnailAssetId: 'thumbnailIdOne',
shared: false,
albumName: 'some album name'
});
sut = render(AlbumCard, { album });
const albumImgElement = sut.getByTestId('album-image');
const albumNameElement = sut.getByTestId('album-name');
const albumDetailsElement = sut.getByTestId('album-details');
// TODO: is this expected?
expect(albumImgElement).toHaveAttribute(
'src',
'/api/asset/thumbnail/thumbnailIdOne?format=WEBP'
);
expect(albumImgElement).toHaveAttribute('alt', album.id);
await waitFor(() => expect(albumImgElement).toHaveAttribute('src', thumbnailUrl));
expect(albumImgElement).toHaveAttribute('alt', album.id);
expect(apiMock.assetApi.getAssetThumbnail).toHaveBeenCalledTimes(1);
expect(apiMock.assetApi.getAssetThumbnail).toHaveBeenCalledWith(
'thumbnailIdOne',
ThumbnailFormat.Jpeg,
{ responseType: 'blob' }
);
expect(createObjectURLMock).toHaveBeenCalledWith(thumbnailBlob);
expect(albumNameElement).toHaveTextContent('some album name');
expect(albumDetailsElement).toHaveTextContent('0 items');
});
describe('with rendered component - no thumbnail', () => {
const album = Object.freeze(albumFactory.build({ albumThumbnailAssetId: null }));
beforeEach(async () => {
sut = render(AlbumCard, { album });
const albumImgElement = sut.getByTestId('album-image');
await waitFor(() => expect(albumImgElement).toHaveAttribute('src', 'no-thumbnail.png'));
});
it('dispatches custom "click" event with the album in context', async () => {
const onClickHandler = jest.fn();
sut.component.$on('click', onClickHandler);
const albumCardElement = sut.getByTestId('album-card');
await fireEvent.click(albumCardElement);
expect(onClickHandler).toHaveBeenCalledTimes(1);
expect(onClickHandler).toHaveBeenCalledWith(expect.objectContaining({ detail: album }));
});
it('dispatches custom "click" event on context menu click with mouse coordinates', async () => {
const onClickHandler = jest.fn();
sut.component.$on('showalbumcontextmenu', onClickHandler);
const contextMenuBtnParent = sut.getByTestId('context-button-parent');
await fireEvent(
contextMenuBtnParent,
new MouseEvent('click', {
clientX: 123,
clientY: 456
})
);
expect(onClickHandler).toHaveBeenCalledTimes(1);
expect(onClickHandler).toHaveBeenCalledWith(
expect.objectContaining({ detail: { x: 123, y: 456 } })
);
});
});
});

View File

@@ -15,12 +15,11 @@
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 { 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;
let imageData: string = `/api/asset/thumbnail/${album.albumThumbnailAssetId}?format=${ThumbnailFormat.Webp}`; let imageData = `/api/asset/thumbnail/${album.albumThumbnailAssetId}?format=${ThumbnailFormat.Webp}`;
const dispatchClick = createEventDispatcher<OnClick>(); const dispatchClick = createEventDispatcher<OnClick>();
const dispatchShowContextMenu = createEventDispatcher<OnShowContextMenu>(); const dispatchShowContextMenu = createEventDispatcher<OnShowContextMenu>();
@@ -29,7 +28,7 @@
return; return;
} }
const { data } = await api.assetApi.getAssetThumbnail(thubmnailId!, ThumbnailFormat.Jpeg, { const { data } = await api.assetApi.getAssetThumbnail(thubmnailId, ThumbnailFormat.Jpeg, {
responseType: 'blob' responseType: 'blob'
}); });
@@ -53,11 +52,13 @@
<div <div
class="h-[339px] w-[275px] hover:cursor-pointer mt-4 relative" class="h-[339px] w-[275px] hover:cursor-pointer mt-4 relative"
on:click={() => dispatchClick('click', album)} on:click={() => dispatchClick('click', album)}
data-testid="album-card"
> >
<div <div
id={`icon-${album.id}`} id={`icon-${album.id}`}
class="absolute top-2 right-2" class="absolute top-2 right-2"
on:click|stopPropagation|preventDefault={showAlbumContextMenu} on:click|stopPropagation|preventDefault={showAlbumContextMenu}
data-testid="context-button-parent"
> >
<CircleIconButton <CircleIconButton
logo={DotsVertical} logo={DotsVertical}
@@ -72,15 +73,16 @@
src={imageData} src={imageData}
alt={album.id} alt={album.id}
class={`object-cover h-full w-full transition-all z-0 rounded-xl duration-300 hover:shadow-lg`} class={`object-cover h-full w-full transition-all z-0 rounded-xl duration-300 hover:shadow-lg`}
data-testid="album-image"
/> />
</div> </div>
<div class="mt-4"> <div class="mt-4">
<p class="text-sm font-medium text-gray-800"> <p class="text-sm font-medium text-gray-800" data-testid="album-name">
{album.albumName} {album.albumName}
</p> </p>
<span class="text-xs flex gap-2"> <span class="text-xs flex gap-2" data-testid="album-details">
<p>{album.assetCount} items</p> <p>{album.assetCount} items</p>
{#if album.shared} {#if album.shared}

View File

@@ -11,7 +11,6 @@
import CircleAvatar from '../shared-components/circle-avatar.svelte'; import CircleAvatar from '../shared-components/circle-avatar.svelte';
import ImmichThumbnail from '../shared-components/immich-thumbnail.svelte'; import ImmichThumbnail from '../shared-components/immich-thumbnail.svelte';
import AssetSelection from './asset-selection.svelte'; import AssetSelection from './asset-selection.svelte';
import _ from 'lodash-es';
import UserSelectionModal from './user-selection-modal.svelte'; import UserSelectionModal from './user-selection-modal.svelte';
import ShareInfoModal from './share-info-modal.svelte'; import ShareInfoModal from './share-info-modal.svelte';
import CircleIconButton from '../shared-components/circle-icon-button.svelte'; import CircleIconButton from '../shared-components/circle-icon-button.svelte';
@@ -27,12 +26,16 @@
NotificationType NotificationType
} from '../shared-components/notification/notification'; } from '../shared-components/notification/notification';
import { browser } from '$app/env'; import { browser } from '$app/env';
import { albumAssetSelectionStore } from '$lib/stores/album-asset-selection.store';
export let album: AlbumResponseDto; export let album: AlbumResponseDto;
const { isAlbumAssetSelectionOpen } = albumAssetSelectionStore;
let isShowAssetViewer = false; let isShowAssetViewer = false;
let isShowAssetSelection = false; let isShowAssetSelection = false;
$: $isAlbumAssetSelectionOpen = isShowAssetSelection;
$: { $: {
if (browser) { if (browser) {
if (isShowAssetSelection) { if (isShowAssetSelection) {
@@ -53,8 +56,7 @@
let currentViewAssetIndex = 0; let currentViewAssetIndex = 0;
let viewWidth: number; let viewWidth: number;
let thumbnailSize: number = 300; let thumbnailSize = 300;
let border = '';
let backUrl = '/albums'; let backUrl = '/albums';
let currentAlbumName = ''; let currentAlbumName = '';
let currentUser: UserResponseDto; let currentUser: UserResponseDto;

View File

@@ -2,9 +2,7 @@
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte'; import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte';
import DotsVertical from 'svelte-material-icons/DotsVertical.svelte';
import CloudDownloadOutline from 'svelte-material-icons/CloudDownloadOutline.svelte'; import CloudDownloadOutline from 'svelte-material-icons/CloudDownloadOutline.svelte';
import TrashCanOutline from 'svelte-material-icons/TrashCanOutline.svelte';
import InformationOutline from 'svelte-material-icons/InformationOutline.svelte'; import InformationOutline from 'svelte-material-icons/InformationOutline.svelte';
import CircleIconButton from '../shared-components/circle-icon-button.svelte'; import CircleIconButton from '../shared-components/circle-icon-button.svelte';
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();

View File

@@ -18,7 +18,7 @@
$: { $: {
appearsInAlbums = []; appearsInAlbums = [];
api.albumApi.getAllAlbums(undefined, asset.id).then(result => { api.albumApi.getAllAlbums(undefined, asset.id).then((result) => {
appearsInAlbums = result.data; appearsInAlbums = result.data;
}); });
} }
@@ -29,12 +29,14 @@
let isShowDetail = false; let isShowDetail = false;
let appearsInAlbums: AlbumResponseDto[] = []; let appearsInAlbums: AlbumResponseDto[] = [];
const onKeyboardPress = (keyInfo: KeyboardEvent) => handleKeyboardPress(keyInfo.key);
onMount(() => { onMount(() => {
document.addEventListener('keydown', (keyInfo) => handleKeyboardPress(keyInfo.key)); document.addEventListener('keydown', onKeyboardPress);
}); });
onDestroy(() => { onDestroy(() => {
document.removeEventListener('keydown', (e) => {}); document.removeEventListener('keydown', onKeyboardPress);
}); });
const handleKeyboardPress = (key: string) => { const handleKeyboardPress = (key: string) => {

View File

@@ -9,10 +9,14 @@
import { browser } from '$app/env'; import { browser } from '$app/env';
import { AssetResponseDto, AlbumResponseDto } from '@api'; import { AssetResponseDto, AlbumResponseDto } from '@api';
type Leaflet = typeof import('leaflet');
type LeafletMap = import('leaflet').Map;
type LeafletMarker = import('leaflet').Marker;
// Map Property // Map Property
let map: any; let map: LeafletMap;
let leaflet: any; let leaflet: Leaflet;
let marker: any; let marker: LeafletMarker;
export let asset: AssetResponseDto; export let asset: AssetResponseDto;
$: if (asset.exifInfo?.latitude != null && asset.exifInfo?.longitude != null) { $: if (asset.exifInfo?.latitude != null && asset.exifInfo?.longitude != null) {
@@ -31,7 +35,6 @@
async function drawMap(lat: number, lon: number) { async function drawMap(lat: number, lon: number) {
if (!leaflet) { if (!leaflet) {
// @ts-ignore
leaflet = await import('leaflet'); leaflet = await import('leaflet');
} }

View File

@@ -10,7 +10,7 @@
export let root: HTMLElement | null = null; export let root: HTMLElement | null = null;
let intersecting = false; let intersecting = false;
let container: any; let container: HTMLDivElement;
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
onMount(() => { onMount(() => {

View File

@@ -33,7 +33,9 @@
const assetData = URL.createObjectURL(data); const assetData = URL.createObjectURL(data);
return assetData; return assetData;
} catch (e) {} } catch {
// Do nothing
}
}; };
</script> </script>

View File

@@ -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, getFileUrl } from '@api'; import { api, AssetResponseDto, getFileUrl } from '@api';
@@ -9,8 +9,6 @@
let asset: AssetResponseDto; let asset: AssetResponseDto;
const dispatch = createEventDispatcher();
let videoPlayerNode: HTMLVideoElement; let videoPlayerNode: HTMLVideoElement;
let isVideoLoading = true; let isVideoLoading = true;
let videoUrl: string; let videoUrl: string;

View File

@@ -5,8 +5,8 @@
let error: string; let error: string;
let success: string; let success: string;
let password: string = ''; let password = '';
let confirmPassowrd: string = ''; let confirmPassowrd = '';
let canRegister = false; let canRegister = false;

View File

@@ -6,8 +6,8 @@
let error: string; let error: string;
let success: string; let success: string;
let password: string = ''; let password = '';
let confirmPassowrd: string = ''; let confirmPassowrd = '';
let changeChagePassword = false; let changeChagePassword = false;

View File

@@ -1,122 +1,121 @@
<script lang="ts"> <script lang="ts">
import { api } from '@api'; import { api } from '@api';
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
let error: string; let error: string;
let success: string; let success: string;
let password = ''; let password = '';
let confirmPassowrd = ''; let confirmPassowrd = '';
let canCreateUser = false; let canCreateUser = false;
$: { $: {
if (password !== confirmPassowrd && confirmPassowrd.length > 0) { if (password !== confirmPassowrd && confirmPassowrd.length > 0) {
error = 'Password does not match'; error = 'Password does not match';
canCreateUser = false; canCreateUser = false;
} else { } else {
error = ''; error = '';
canCreateUser = true; canCreateUser = true;
} }
} }
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
async function registerUser(event: SubmitEvent) { async function registerUser(event: SubmitEvent) {
if (canCreateUser) { if (canCreateUser) {
error = ''; error = '';
const formElement = event.target as HTMLFormElement; const formElement = event.target as HTMLFormElement;
const form = new FormData(formElement); const form = new FormData(formElement);
const email = form.get('email'); const email = form.get('email');
const password = form.get('password'); const password = form.get('password');
const firstName = form.get('firstName'); const firstName = form.get('firstName');
const lastName = form.get('lastName'); const lastName = form.get('lastName');
const {status} = await api.userApi.createUser({ const { status } = await api.userApi.createUser({
email: String(email), email: String(email),
password: String(password), password: String(password),
firstName: String(firstName), firstName: String(firstName),
lastName: String(lastName) lastName: String(lastName)
}); });
if (status === 201) { if (status === 201) {
success = 'New user created'; success = 'New user created';
dispatch('user-created'); dispatch('user-created');
return; return;
} else { } else {
error = 'Error create user account'; error = 'Error create user account';
} }
} }
} }
</script> </script>
<div class="border bg-white p-4 shadow-sm w-[500px] rounded-3xl py-8"> <div class="border bg-white p-4 shadow-sm w-[500px] rounded-3xl py-8">
<div class="flex flex-col place-items-center place-content-center gap-4 px-4"> <div class="flex flex-col place-items-center place-content-center gap-4 px-4">
<img class="text-center" src="/immich-logo.svg" height="100" width="100" alt="immich-logo"/> <img class="text-center" src="/immich-logo.svg" height="100" width="100" alt="immich-logo" />
<h1 class="text-2xl text-immich-primary font-medium">Create new user</h1> <h1 class="text-2xl text-immich-primary font-medium">Create new user</h1>
<p class="text-sm border rounded-md p-4 font-mono text-gray-600"> <p class="text-sm border rounded-md p-4 font-mono text-gray-600">
Please provide your user with the password, they will have to change it on their first sign Please provide your user with the password, they will have to change it on their first sign
in. in.
</p> </p>
</div> </div>
<form on:submit|preventDefault={registerUser} autocomplete="off"> <form on:submit|preventDefault={registerUser} autocomplete="off">
<div class="m-4 flex flex-col gap-2"> <div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="email">Email</label> <label class="immich-form-label" for="email">Email</label>
<input class="immich-form-input" id="email" name="email" type="email" required/> <input class="immich-form-input" id="email" name="email" type="email" required />
</div> </div>
<div class="m-4 flex flex-col gap-2"> <div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="password">Password</label> <label class="immich-form-label" for="password">Password</label>
<input <input
class="immich-form-input" class="immich-form-input"
id="password" id="password"
name="password" name="password"
type="password" type="password"
required required
bind:value={password} bind:value={password}
/> />
</div> </div>
<div class="m-4 flex flex-col gap-2"> <div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="confirmPassword">Confirm Password</label> <label class="immich-form-label" for="confirmPassword">Confirm Password</label>
<input <input
class="immich-form-input" class="immich-form-input"
id="confirmPassword" id="confirmPassword"
name="password" name="password"
type="password" type="password"
required required
bind:value={confirmPassowrd} bind:value={confirmPassowrd}
/> />
</div> </div>
<div class="m-4 flex flex-col gap-2"> <div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="firstName">First Name</label> <label class="immich-form-label" for="firstName">First Name</label>
<input class="immich-form-input" id="firstName" name="firstName" type="text" required/> <input class="immich-form-input" id="firstName" name="firstName" type="text" required />
</div> </div>
<div class="m-4 flex flex-col gap-2"> <div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="lastName">Last Name</label> <label class="immich-form-label" for="lastName">Last Name</label>
<input class="immich-form-input" id="lastName" name="lastName" type="text" required/> <input class="immich-form-input" id="lastName" name="lastName" type="text" required />
</div> </div>
{#if error} {#if error}
<p class="text-red-400 ml-4 text-sm">{error}</p> <p class="text-red-400 ml-4 text-sm">{error}</p>
{/if} {/if}
{#if success} {#if success}
<p class="text-immich-primary ml-4 text-sm">{success}</p> <p class="text-immich-primary ml-4 text-sm">{success}</p>
{/if} {/if}
<div class="flex w-full"> <div class="flex w-full">
<button <button
type="submit" type="submit"
class="m-4 bg-immich-primary hover:bg-immich-primary/75 px-6 py-3 text-white rounded-full shadow-md w-full font-medium" class="m-4 bg-immich-primary hover:bg-immich-primary/75 px-6 py-3 text-white rounded-full shadow-md w-full font-medium"
>Create >Create
</button </button>
> </div>
</div> </form>
</form>
</div> </div>

View File

@@ -14,7 +14,6 @@
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
// eslint-disable-next-line no-undef
const editUser = async (event: SubmitEvent) => { const editUser = async (event: SubmitEvent) => {
try { try {
const formElement = event.target as HTMLFormElement; const formElement = event.target as HTMLFormElement;
@@ -25,8 +24,8 @@
const { status } = await api.userApi.updateUser({ const { status } = await api.userApi.updateUser({
id: user.id, id: user.id,
firstName: firstName!.toString(), firstName: firstName?.toString(),
lastName: lastName!.toString() lastName: lastName?.toString()
}); });
if (status == 200) { if (status == 200) {

View File

@@ -4,8 +4,8 @@
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
let error: string; let error: string;
let email: string = ''; let email = '';
let password: string = ''; let password = '';
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();

View File

@@ -7,7 +7,6 @@
import lodash from 'lodash-es'; import lodash from 'lodash-es';
import moment from 'moment'; import moment from 'moment';
import ImmichThumbnail from '../shared-components/immich-thumbnail.svelte'; import ImmichThumbnail from '../shared-components/immich-thumbnail.svelte';
import { createEventDispatcher } from 'svelte';
import { import {
assetInteractionStore, assetInteractionStore,
assetsInAlbumStoreState, assetsInAlbumStoreState,
@@ -22,7 +21,7 @@
let isMouseOverGroup = false; let isMouseOverGroup = false;
let actualBucketHeight: number; let actualBucketHeight: number;
let hoveredDateGroup: string = ''; let hoveredDateGroup = '';
$: assetsGroupByDate = lodash $: assetsGroupByDate = lodash
.chain(assets) .chain(assets)
.groupBy((a) => moment(a.createdAt).format('ddd, MMM DD YYYY')) .groupBy((a) => moment(a.createdAt).format('ddd, MMM DD YYYY'))

View File

@@ -3,7 +3,7 @@
import IntersectionObserver from '../asset-viewer/intersection-observer.svelte'; import IntersectionObserver from '../asset-viewer/intersection-observer.svelte';
import { assetGridState, assetStore, loadingBucketState } from '$lib/stores/assets.store'; import { assetGridState, assetStore, loadingBucketState } from '$lib/stores/assets.store';
import { api, TimeGroupEnum } from '@api'; import { api, AssetCountByTimeBucketResponseDto, TimeGroupEnum } from '@api';
import AssetDateGroup from './asset-date-group.svelte'; import AssetDateGroup from './asset-date-group.svelte';
import Portal from '../shared-components/portal/portal.svelte'; import Portal from '../shared-components/portal/portal.svelte';
import AssetViewer from '../asset-viewer/asset-viewer.svelte'; import AssetViewer from '../asset-viewer/asset-viewer.svelte';
@@ -12,16 +12,23 @@
isViewingAssetStoreState, isViewingAssetStoreState,
viewingAssetStoreState viewingAssetStoreState
} from '$lib/stores/asset-interaction.store'; } from '$lib/stores/asset-interaction.store';
import Scrollbar, {
OnScrollbarClickDetail,
OnScrollbarDragDetail
} from '../shared-components/scrollbar/scrollbar.svelte';
export let isAlbumSelectionMode = false;
let viewportHeight = 0; let viewportHeight = 0;
let viewportWidth = 0; let viewportWidth = 0;
let assetGridElement: HTMLElement; let assetGridElement: HTMLElement;
export let isAlbumSelectionMode = false; let bucketInfo: AssetCountByTimeBucketResponseDto;
onMount(async () => { onMount(async () => {
const { data: assetCountByTimebucket } = await api.assetApi.getAssetCountByTimeBucket({ const { data: assetCountByTimebucket } = await api.assetApi.getAssetCountByTimeBucket({
timeGroup: TimeGroupEnum.Month timeGroup: TimeGroupEnum.Month
}); });
bucketInfo = assetCountByTimebucket;
assetStore.setInitialState(viewportHeight, viewportWidth, assetCountByTimebucket); assetStore.setInitialState(viewportHeight, viewportWidth, assetCountByTimebucket);
@@ -60,14 +67,46 @@
const navigateToNextAsset = () => { const navigateToNextAsset = () => {
assetInteractionStore.navigateAsset('next'); assetInteractionStore.navigateAsset('next');
}; };
let lastScrollPosition = 0;
let animationTick = false;
const handleTimelineScroll = () => {
if (!animationTick) {
window.requestAnimationFrame(() => {
lastScrollPosition = assetGridElement?.scrollTop;
animationTick = false;
});
animationTick = true;
}
};
const handleScrollbarClick = (e: OnScrollbarClickDetail) => {
assetGridElement.scrollTop = e.scrollTo;
};
const handleScrollbarDrag = (e: OnScrollbarDragDetail) => {
assetGridElement.scrollTop = e.scrollTo;
};
</script> </script>
{#if bucketInfo && viewportHeight && $assetGridState.timelineHeight > viewportHeight}
<Scrollbar
scrollbarHeight={viewportHeight}
scrollTop={lastScrollPosition}
on:onscrollbarclick={(e) => handleScrollbarClick(e.detail)}
on:onscrollbardrag={(e) => handleScrollbarDrag(e.detail)}
/>
{/if}
<section <section
id="asset-grid" id="asset-grid"
class="overflow-y-auto pl-4" class="overflow-y-auto pl-4 scrollbar-hidden"
bind:clientHeight={viewportHeight} bind:clientHeight={viewportHeight}
bind:clientWidth={viewportWidth} bind:clientWidth={viewportWidth}
bind:this={assetGridElement} bind:this={assetGridElement}
on:scroll={handleTimelineScroll}
> >
{#if assetGridElement} {#if assetGridElement}
<section id="virtual-timeline" style:height={$assetGridState.timelineHeight + 'px'}> <section id="virtual-timeline" style:height={$assetGridState.timelineHeight + 'px'}>
@@ -117,5 +156,6 @@
<style> <style>
#asset-grid { #asset-grid {
contain: layout; contain: layout;
scrollbar-width: none;
} }
</style> </style>

View File

@@ -1,8 +1,5 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation';
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import { page } from '$app/stores';
import FullScreenModal from './full-screen-modal.svelte'; import FullScreenModal from './full-screen-modal.svelte';
export let localVersion: string; export let localVersion: string;
export let remoteVersion: string; export let remoteVersion: string;

View File

@@ -22,7 +22,7 @@
onDestroy(() => { onDestroy(() => {
if (browser) { if (browser) {
window.onscroll = function () {}; window.onscroll = null;
} }
}); });
</script> </script>

View File

@@ -5,7 +5,7 @@
export let user: UserResponseDto; export let user: UserResponseDto;
// Avatar Size In Pixel // Avatar Size In Pixel
export let size: number = 48; export let size = 48;
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();

View File

@@ -4,10 +4,12 @@
*/ */
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
// TODO: why any here?
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export let logo: any; export let logo: any;
export let backgroundColor: string = 'transparent'; export let backgroundColor = 'transparent';
export let hoverColor: string = '#e2e7e9'; export let hoverColor = '#e2e7e9';
export let logoColor: string = '#5f6368'; export let logoColor = '#5f6368';
export let size = '24'; export let size = '24';
export let title = ''; export let title = '';
let iconButton: HTMLButtonElement; let iconButton: HTMLButtonElement;

View File

@@ -6,15 +6,13 @@
/** /**
* x coordiante of the context menu. * x coordiante of the context menu.
* @type {number}
*/ */
export let x: number = 0; export let x = 0;
/** /**
* x coordiante of the context menu. * x coordiante of the context menu.
* @type {number}
*/ */
export let y: number = 0; export let y = 0;
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();

View File

@@ -12,21 +12,23 @@
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
const onScroll = () => {
if (window.pageYOffset > 80) {
appBarBorder = 'border border-gray-200 bg-gray-50';
} else {
appBarBorder = 'bg-immich-bg border border-transparent';
}
};
onMount(() => { onMount(() => {
if (browser) { if (browser) {
document.addEventListener('scroll', (e) => { document.addEventListener('scroll', onScroll);
if (window.pageYOffset > 80) {
appBarBorder = 'border border-gray-200 bg-gray-50';
} else {
appBarBorder = 'bg-immich-bg border border-transparent';
}
});
} }
}); });
onDestroy(() => { onDestroy(() => {
if (browser) { if (browser) {
document.removeEventListener('scroll', (e) => {}); document.removeEventListener('scroll', onScroll);
} }
}); });
</script> </script>

View File

@@ -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, getFileUrl, ThumbnailFormat } from '@api'; import { AssetResponseDto, AssetTypeEnum, getFileUrl, ThumbnailFormat } from '@api';
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
@@ -14,14 +14,14 @@
export let groupIndex = 0; export let groupIndex = 0;
export let thumbnailSize: number | undefined = undefined; export let thumbnailSize: number | undefined = undefined;
export let format: ThumbnailFormat = ThumbnailFormat.Webp; export let format: ThumbnailFormat = ThumbnailFormat.Webp;
export let selected: boolean = false; export let selected = false;
export let disabled: boolean = false; export let disabled = false;
let imageData: string; let imageData: string;
let mouseOver: boolean = false; let mouseOver = false;
$: dispatch('mouse-event', { isMouseOver: mouseOver, selectedGroupIndex: groupIndex }); $: dispatch('mouse-event', { isMouseOver: mouseOver, selectedGroupIndex: groupIndex });
let mouseOverIcon: boolean = false; let mouseOverIcon = false;
let videoPlayerNode: HTMLVideoElement; let videoPlayerNode: HTMLVideoElement;
let isThumbnailVideoPlaying = false; let isThumbnailVideoPlaying = false;
let calculateVideoDurationIntervalHandler: NodeJS.Timer; let calculateVideoDurationIntervalHandler: NodeJS.Timer;
@@ -136,7 +136,7 @@
<div <div
style:width={`${thumbnailSize}px`} style:width={`${thumbnailSize}px`}
style:height={`${thumbnailSize}px`} style:height={`${thumbnailSize}px`}
class={`bg-gray-100 relative ${getSize()} ${ class={`bg-gray-100 relative select-none ${getSize()} ${
disabled ? 'cursor-not-allowed' : 'hover:cursor-pointer' disabled ? 'cursor-not-allowed' : 'hover:cursor-pointer'
}`} }`}
on:mouseenter={handleMouseOverThumbnail} on:mouseenter={handleMouseOverThumbnail}

View File

@@ -2,7 +2,7 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { createEventDispatcher, onMount } from 'svelte'; import { createEventDispatcher, onMount } from 'svelte';
import { fade, fly, slide } from 'svelte/transition'; import { fade, fly } 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, UserResponseDto } from '@api'; import { api, UserResponseDto } from '@api';

View File

@@ -0,0 +1,39 @@
import { jest, describe, it } from '@jest/globals';
import { render, cleanup, RenderResult } from '@testing-library/svelte';
import { NotificationType } from '../notification';
import NotificationCard from '../notification-card.svelte';
import '@testing-library/jest-dom';
describe('NotificationCard component', () => {
let sut: RenderResult<NotificationCard>;
it('disposes timeout if already removed from the DOM', () => {
jest.spyOn(window, 'clearTimeout');
sut = render(NotificationCard, {
notificationInfo: {
id: 1234,
message: 'Notification message',
timeout: 1000,
type: NotificationType.Info
}
});
cleanup();
expect(window.clearTimeout).toHaveBeenCalledTimes(1);
});
it('shows message and title', () => {
sut = render(NotificationCard, {
notificationInfo: {
id: 1234,
message: 'Notification message',
timeout: 1000,
type: NotificationType.Info
}
});
expect(sut.getByTestId('title')).toHaveTextContent('Info');
expect(sut.getByTestId('message')).toHaveTextContent('Notification message');
});
});

View File

@@ -0,0 +1,44 @@
import { jest, describe, it } from '@jest/globals';
import { render, RenderResult, waitFor } from '@testing-library/svelte';
import { notificationController, NotificationType } from '../notification';
import { get } from 'svelte/store';
import NotificationList from '../notification-list.svelte';
import '@testing-library/jest-dom';
function _getNotificationListElement(
sut: RenderResult<NotificationList>
): HTMLAnchorElement | null {
return sut.container.querySelector('#notification-list');
}
describe('NotificationList component', () => {
const sut: RenderResult<NotificationList> = render(NotificationList);
beforeAll(() => {
jest.useFakeTimers();
});
afterAll(() => {
jest.useRealTimers();
});
it('shows a notification when added and closes it automatically after the delay timeout', async () => {
expect(_getNotificationListElement(sut)).not.toBeInTheDocument();
notificationController.show({
message: 'Notification',
type: NotificationType.Info,
timeout: 3000
});
await waitFor(() => expect(_getNotificationListElement(sut)).toBeInTheDocument());
expect(_getNotificationListElement(sut)?.children).toHaveLength(1);
jest.advanceTimersByTime(3000);
// due to some weirdness in svelte (or testing-library) need to check if it has been removed from the store to make sure it works.
expect(get(notificationController.notificationList)).toHaveLength(0);
await waitFor(() => expect(_getNotificationListElement(sut)).not.toBeInTheDocument());
});
});

View File

@@ -48,10 +48,14 @@
} }
}; };
let removeNotificationTimeout: NodeJS.Timeout | undefined = undefined;
onMount(() => { onMount(() => {
setTimeout(() => { removeNotificationTimeout = setTimeout(() => {
notificationController.removeNotificationById(notificationInfo.id); notificationController.removeNotificationById(notificationInfo.id);
}, notificationInfo.timeout); }, notificationInfo.timeout);
return () => clearTimeout(removeNotificationTimeout);
}); });
</script> </script>
@@ -63,8 +67,10 @@
> >
<div class="flex gap-2 place-items-center"> <div class="flex gap-2 place-items-center">
<svelte:component this={icon} color={primaryColor()} size="20" /> <svelte:component this={icon} color={primaryColor()} size="20" />
<h2 style:color={primaryColor()} class="font-medium">{notificationInfo.type.toString()}</h2> <h2 style:color={primaryColor()} class="font-medium" data-testid="title">
{notificationInfo.type.toString()}
</h2>
</div> </div>
<p class="text-sm pl-[28px] pr-[16px]">{notificationInfo.message}</p> <p class="text-sm pl-[28px] pr-[16px]" data-testid="message">{notificationInfo.message}</p>
</div> </div>

View File

@@ -1,25 +1,21 @@
<script lang="ts"> <script lang="ts">
import { ImmichNotification, notificationController } from './notification'; import { notificationController } from './notification';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import NotificationCard from './notification-card.svelte'; import NotificationCard from './notification-card.svelte';
import { flip } from 'svelte/animate'; import { flip } from 'svelte/animate';
import { quintOut } from 'svelte/easing'; import { quintOut } from 'svelte/easing';
let notificationList: ImmichNotification[] = []; const { notificationList } = notificationController;
notificationController.notificationList.subscribe((list) => {
notificationList = list;
});
</script> </script>
{#if notificationList.length > 0} {#if $notificationList.length > 0}
<section <section
transition:fade={{ duration: 250 }} transition:fade={{ duration: 250 }}
id="notification-list" id="notification-list"
class="absolute right-5 top-[80px] z-[99999999]" class="absolute right-5 top-[80px] z-[99999999]"
> >
{#each notificationList as notificationInfo (notificationInfo.id)} {#each $notificationList as notificationInfo (notificationInfo.id)}
<div animate:flip={{ duration: 250, easing: quintOut }}> <div animate:flip={{ duration: 250, easing: quintOut }}>
<NotificationCard {notificationInfo} /> <NotificationCard {notificationInfo} />
</div> </div>

View File

@@ -3,13 +3,10 @@
/** /**
* Usage: <div use:portal={'css selector'}> or <div use:portal={document.body}> * Usage: <div use:portal={'css selector'}> or <div use:portal={document.body}>
*
* @param {HTMLElement} el
* @param {HTMLElement|string} target DOM Element or CSS Selector
*/ */
export function portal(el: any, target: any = 'body') { export function portal(el: HTMLElement, target: HTMLElement | string = 'body') {
let targetEl; let targetEl;
async function update(newTarget: any) { async function update(newTarget: HTMLElement | string) {
target = newTarget; target = newTarget;
if (typeof target === 'string') { if (typeof target === 'string') {
targetEl = document.querySelector(target); targetEl = document.querySelector(target);

View File

@@ -1,76 +1,111 @@
<script lang="ts" context="module">
type OnScrollbarClick = {
onscrollbarclick: OnScrollbarClickDetail;
};
export type OnScrollbarClickDetail = {
scrollTo: number;
};
type OnScrollbarDrag = {
onscrollbardrag: OnScrollbarDragDetail;
};
export type OnScrollbarDragDetail = {
scrollTo: number;
};
</script>
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { albumAssetSelectionStore } from '$lib/stores/album-asset-selection.store';
import { assetGridState } from '$lib/stores/assets.store';
import { createEventDispatcher } from 'svelte';
import { SegmentScrollbarLayout } from './segment-scrollbar-layout'; import { SegmentScrollbarLayout } from './segment-scrollbar-layout';
export let scrollTop = 0; export let scrollTop = 0;
export let viewportWidth = 0;
export let scrollbarHeight = 0; export let scrollbarHeight = 0;
let timelineHeight = 0; $: timelineHeight = $assetGridState.timelineHeight;
$: viewportWidth = $assetGridState.viewportWidth;
$: timelineScrolltop = (scrollbarPosition / scrollbarHeight) * timelineHeight;
let segmentScrollbarLayout: SegmentScrollbarLayout[] = []; let segmentScrollbarLayout: SegmentScrollbarLayout[] = [];
let isHover = false; let isHover = false;
let isDragging = false;
let hoveredDate: Date; let hoveredDate: Date;
let currentMouseYLocation: number = 0; let currentMouseYLocation = 0;
let scrollbarPosition = 0; let scrollbarPosition = 0;
let animationTick = false;
const { isAlbumAssetSelectionOpen } = albumAssetSelectionStore;
$: offset = $isAlbumAssetSelectionOpen ? 100 : 71;
const dispatchClick = createEventDispatcher<OnScrollbarClick>();
const dispatchDrag = createEventDispatcher<OnScrollbarDrag>();
$: { $: {
scrollbarPosition = (scrollTop / timelineHeight) * scrollbarHeight; scrollbarPosition = (scrollTop / timelineHeight) * scrollbarHeight;
} }
$: { $: {
// let result: SegmentScrollbarLayout[] = []; let result: SegmentScrollbarLayout[] = [];
// for (const [i, segment] of assetStoreState.entries()) { for (const bucket of $assetGridState.buckets) {
// let segmentLayout = new SegmentScrollbarLayout(); let segmentLayout = new SegmentScrollbarLayout();
// segmentLayout.count = segmentData.groups[i].count; segmentLayout.count = bucket.assets.length;
// segmentLayout.height = segmentLayout.height = (bucket.bucketHeight / timelineHeight) * scrollbarHeight;
// segment.assets.length == 0 segmentLayout.timeGroup = bucket.bucketDate;
// ? getSegmentHeight(segmentData.groups[i].count) result.push(segmentLayout);
// : Math.round((segment.segmentHeight / timelineHeight) * scrollbarHeight); }
// segmentLayout.timeGroup = segment.segmentDate; segmentScrollbarLayout = result;
// result.push(segmentLayout);
// }
// segmentScrollbarLayout = result;
} }
onMount(() => {
// segmentScrollbarLayout = getLayoutDistance();
return () => {};
});
const getSegmentHeight = (groupCount: number) => {
// if (segmentData.groups.length > 0) {
// const percentage = (groupCount * 100) / segmentData.totalAssets;
// return Math.round((percentage * scrollbarHeight) / 100);
// } else {
// return 0;
// }
};
const getLayoutDistance = () => {
// let result: SegmentScrollbarLayout[] = [];
// for (const segment of segmentData.groups) {
// let segmentLayout = new SegmentScrollbarLayout();
// segmentLayout.count = segment.count;
// segmentLayout.height = getSegmentHeight(segment.count);
// segmentLayout.timeGroup = segment.timeGroup;
// result.push(segmentLayout);
// }
// return result;
};
const handleMouseMove = (e: MouseEvent, currentDate: Date) => { const handleMouseMove = (e: MouseEvent, currentDate: Date) => {
currentMouseYLocation = e.clientY - 71 - 30; currentMouseYLocation = e.clientY - offset - 30;
hoveredDate = new Date(currentDate.toISOString().slice(0, -1)); hoveredDate = new Date(currentDate.toISOString().slice(0, -1));
}; };
const handleMouseDown = (e: MouseEvent) => {
isDragging = true;
scrollbarPosition = e.clientY - offset;
};
const handleMouseUp = (e: MouseEvent) => {
isDragging = false;
scrollbarPosition = e.clientY - offset;
dispatchClick('onscrollbarclick', { scrollTo: timelineScrolltop });
};
const handleMouseDrag = (e: MouseEvent) => {
if (isDragging) {
if (!animationTick) {
window.requestAnimationFrame(() => {
const dy = e.clientY - scrollbarPosition - offset;
scrollbarPosition += dy;
dispatchDrag('onscrollbardrag', { scrollTo: timelineScrolltop });
animationTick = false;
});
animationTick = true;
}
}
};
</script> </script>
<div <div
id="immich-scubbable-scrollbar" id="immich-scrubbable-scrollbar"
class="fixed right-0 w-[60px] h-full bg-immich-bg z-[9999] hover:cursor-row-resize" class="fixed right-0 bg-immich-bg z-10 hover:cursor-row-resize select-none"
style:width={isDragging ? '100vw' : '60px'}
style:background-color={isDragging ? 'transparent' : 'transparent'}
on:mouseenter={() => (isHover = true)} on:mouseenter={() => (isHover = true)}
on:mouseleave={() => (isHover = false)} on:mouseleave={() => {
isHover = false;
isDragging = false;
}}
on:mouseup={handleMouseUp}
on:mousemove={handleMouseDrag}
on:mousedown={handleMouseDown}
style:height={scrollbarHeight + 'px'}
> >
{#if isHover} {#if isHover}
<div <div
@@ -83,29 +118,33 @@
{/if} {/if}
<!-- Scroll Position Indicator Line --> <!-- Scroll Position Indicator Line -->
<div {#if !isDragging}
class="absolute right-0 w-10 h-[2px] bg-immich-primary" <div
style:top={scrollbarPosition + 'px'} class="absolute right-0 w-10 h-[2px] bg-immich-primary"
/> style:top={scrollbarPosition + 'px'}
/>
{/if}
<!-- Time Segment --> <!-- Time Segment -->
{#each segmentScrollbarLayout as segment, index (segment.timeGroup)} {#each segmentScrollbarLayout as segment, index (segment.timeGroup)}
{@const groupDate = new Date(segment.timeGroup)} {@const groupDate = new Date(segment.timeGroup)}
<div <div
class="relative " id="time-segment"
class="relative"
style:height={segment.height + 'px'} style:height={segment.height + 'px'}
aria-label={segment.timeGroup + ' ' + segment.count} aria-label={segment.timeGroup + ' ' + segment.count}
on:mousemove={(e) => handleMouseMove(e, groupDate)} on:mousemove={(e) => handleMouseMove(e, groupDate)}
> >
{#if new Date(segmentScrollbarLayout[index - 1]?.timeGroup).getFullYear() !== groupDate.getFullYear()} {#if new Date(segmentScrollbarLayout[index - 1]?.timeGroup).getFullYear() !== groupDate.getFullYear()}
<div {#if segment.height > 8}
aria-label={segment.timeGroup + ' ' + segment.count} <div
class="absolute right-0 pr-3 z-10 text-xs font-medium" aria-label={segment.timeGroup + ' ' + segment.count}
> class="absolute right-0 pr-5 z-10 text-xs font-medium"
{groupDate.getFullYear()} >
</div> {groupDate.getFullYear()}
{:else if segment.count > 5} </div>
{/if}
{:else if segment.height > 5}
<div <div
aria-label={segment.timeGroup + ' ' + segment.count} aria-label={segment.timeGroup + ' ' + segment.count}
class="absolute right-0 rounded-full h-[4px] w-[4px] mr-3 bg-gray-300 block" class="absolute right-0 rounded-full h-[4px] w-[4px] mr-3 bg-gray-300 block"
@@ -116,7 +155,8 @@
</div> </div>
<style> <style>
#immich-scubbable-scrollbar { #immich-scrubbable-scrollbar,
#time-segment {
contain: layout; contain: layout;
} }
</style> </style>

View File

@@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
export let title: string; export let title: string;
// TODO: why `any` here? There should be a expected type for this
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export let logo: any; export let logo: any;
export let actionType: AdminSideBarSelection | AppSideBarSelection; export let actionType: AdminSideBarSelection | AppSideBarSelection;
export let isSelected: boolean; export let isSelected: boolean;

View File

@@ -8,16 +8,17 @@
import InformationOutline from 'svelte-material-icons/InformationOutline.svelte'; import InformationOutline from 'svelte-material-icons/InformationOutline.svelte';
import SideBarButton from './side-bar-button.svelte'; import SideBarButton from './side-bar-button.svelte';
import StatusBox from '../status-box.svelte'; import StatusBox from '../status-box.svelte';
import { AlbumCountResponseDto, api, AssetCountByUserIdResponseDto } from '@api'; import { api } from '@api';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import LoadingSpinner from '../loading-spinner.svelte'; import LoadingSpinner from '../loading-spinner.svelte';
let selectedAction: AppSideBarSelection; let selectedAction: AppSideBarSelection;
let showAssetCount: boolean = false; let showAssetCount = false;
let showSharingCount = false; let showSharingCount = false;
let showAlbumsCount = false; let showAlbumsCount = false;
// let domCount = 0;
onMount(async () => { onMount(async () => {
if ($page.routeId == 'albums') { if ($page.routeId == 'albums') {
selectedAction = AppSideBarSelection.ALBUMS; selectedAction = AppSideBarSelection.ALBUMS;
@@ -26,6 +27,10 @@
} else if ($page.routeId == 'sharing') { } else if ($page.routeId == 'sharing') {
selectedAction = AppSideBarSelection.SHARING; selectedAction = AppSideBarSelection.SHARING;
} }
// setInterval(() => {
// domCount = document.getElementsByTagName('*').length;
// }, 500);
}); });
const getAssetCount = async () => { const getAssetCount = async () => {
@@ -48,6 +53,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">
<!-- {domCount} -->
<a <a
sveltekit:prefetch sveltekit:prefetch
sveltekit:noscroll sveltekit:noscroll
@@ -66,12 +72,12 @@
on:mouseenter={() => (showAssetCount = true)} on:mouseenter={() => (showAssetCount = true)}
on:mouseleave={() => (showAssetCount = false)} on:mouseleave={() => (showAssetCount = false)}
> >
<InformationOutline size={18} color="#4250af" /> <InformationOutline size={18} color="#989a9f" />
{#if showAssetCount} {#if showAssetCount}
<div <div
transition:fade={{ duration: 200 }} transition:fade={{ duration: 200 }}
id="asset-count-info-detail" id="asset-count-info-detail"
class="w-32 rounded-lg px-4 py-2 shadow-lg bg-white absolute -right-[135px] top-0 z-[9999] flex place-items-center place-content-center" class="w-32 rounded-lg p-4 shadow-lg bg-white absolute -right-[135px] top-0 z-[9999] flex place-items-center place-content-center"
> >
{#await getAssetCount()} {#await getAssetCount()}
<LoadingSpinner /> <LoadingSpinner />
@@ -99,18 +105,18 @@
on:mouseenter={() => (showSharingCount = true)} on:mouseenter={() => (showSharingCount = true)}
on:mouseleave={() => (showSharingCount = false)} on:mouseleave={() => (showSharingCount = false)}
> >
<InformationOutline size={18} color="#4250af" /> <InformationOutline size={18} color="#989a9f" />
{#if showSharingCount} {#if showSharingCount}
<div <div
transition:fade={{ duration: 200 }} transition:fade={{ duration: 200 }}
id="asset-count-info-detail" id="asset-count-info-detail"
class="w-32 rounded-lg px-4 py-2 shadow-lg bg-white absolute -right-[135px] top-0 z-[9999] flex place-items-center place-content-center" class="w-24 rounded-lg p-4 shadow-lg bg-white absolute -right-[105px] top-0 z-[9999] flex place-items-center place-content-center"
> >
{#await getAlbumCount()} {#await getAlbumCount()}
<LoadingSpinner /> <LoadingSpinner />
{:then data} {:then data}
<div> <div>
<p>{data.shared + data.sharing} albums</p> <p>{data.shared + data.sharing} Albums</p>
</div> </div>
{/await} {/await}
</div> </div>
@@ -134,18 +140,18 @@
on:mouseenter={() => (showAlbumsCount = true)} on:mouseenter={() => (showAlbumsCount = true)}
on:mouseleave={() => (showAlbumsCount = false)} on:mouseleave={() => (showAlbumsCount = false)}
> >
<InformationOutline size={18} color="#4250af" /> <InformationOutline size={18} color="#989a9f" />
{#if showAlbumsCount} {#if showAlbumsCount}
<div <div
transition:fade={{ duration: 200 }} transition:fade={{ duration: 200 }}
id="asset-count-info-detail" id="asset-count-info-detail"
class="w-32 rounded-lg px-4 py-2 shadow-lg bg-white absolute -right-[135px] top-0 z-[9999] flex place-items-center place-content-center" class="w-24 rounded-lg p-4 shadow-lg bg-white absolute -right-[105px] top-0 z-[9999] flex place-items-center place-content-center"
> >
{#await getAlbumCount()} {#await getAlbumCount()}
<LoadingSpinner /> <LoadingSpinner />
{:then data} {:then data}
<div> <div>
<p>{data.owned} albums</p> <p>{data.owned} Albums</p>
</div> </div>
{/await} {/await}
</div> </div>

View File

@@ -5,8 +5,6 @@
import CloudUploadOutline from 'svelte-material-icons/CloudUploadOutline.svelte'; import CloudUploadOutline from 'svelte-material-icons/CloudUploadOutline.svelte';
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 { goto } from '$app/navigation';
import { assetStore } from '$lib/stores/assets.store';
import { notificationController, NotificationType } from './notification/notification'; import { notificationController, NotificationType } from './notification/notification';
let showDetail = true; let showDetail = true;
@@ -22,9 +20,13 @@
const blob = new Blob([arrayBufferView], { type: 'image/jpeg' }); const blob = new Blob([arrayBufferView], { type: 'image/jpeg' });
const urlCreator = window.URL || window.webkitURL; const urlCreator = window.URL || window.webkitURL;
const imageUrl = urlCreator.createObjectURL(blob); const imageUrl = urlCreator.createObjectURL(blob);
// TODO: There is probably a cleaner way of doing this
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const img: any = document.getElementById(`${a.id}`); const img: any = document.getElementById(`${a.id}`);
img.src = imageUrl; img.src = imageUrl;
} catch (e) {} } catch {
// Do nothing?
}
} }
}; };

View File

@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { AlbumResponseDto, api, ThumbnailFormat, UserResponseDto } from '@api'; import { AlbumResponseDto, api, ThumbnailFormat, UserResponseDto } from '@api';
import { onMount } from 'svelte';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
export let album: AlbumResponseDto; export let album: AlbumResponseDto;
@@ -11,7 +10,7 @@
return '/no-thumbnail.png'; return '/no-thumbnail.png';
} }
const { data } = await api.assetApi.getAssetThumbnail(thubmnailId!, ThumbnailFormat.Webp, { const { data } = await api.assetApi.getAssetThumbnail(thubmnailId, ThumbnailFormat.Webp, {
responseType: 'blob' responseType: 'blob'
}); });
if (data instanceof Blob) { if (data instanceof Blob) {

View File

@@ -1,2 +1,2 @@
import { env } from '$env/dynamic/public'; import { env } from '$env/dynamic/public';
export const loginPageMessage: string = env.PUBLIC_LOGIN_PAGE_MESSAGE; export const loginPageMessage: string = env.PUBLIC_LOGIN_PAGE_MESSAGE;

View File

@@ -16,17 +16,17 @@ export class AssetGridState {
* The total height of the timeline in pixel * The total height of the timeline in pixel
* This value is first estimated by the number of asset and later is corrected as the user scroll * This value is first estimated by the number of asset and later is corrected as the user scroll
*/ */
timelineHeight: number = 0; timelineHeight = 0;
/** /**
* The fixed viewport height in pixel * The fixed viewport height in pixel
*/ */
viewportHeight: number = 0; viewportHeight = 0;
/** /**
* The fixed viewport width in pixel * The fixed viewport width in pixel
*/ */
viewportWidth: number = 0; viewportWidth = 0;
/** /**
* List of bucket information * List of bucket information

View File

@@ -0,0 +1,10 @@
import { writable } from 'svelte/store';
function createAlbumAssetSelectionStore() {
const isAlbumAssetSelectionOpen = writable<boolean>(false);
return {
isAlbumAssetSelectionOpen
};
}
export const albumAssetSelectionStore = createAlbumAssetSelectionStore();

View File

@@ -1,9 +1,6 @@
import { TimeGroupEnum } from './../../api/open-api/api'; import { writable } from 'svelte/store';
import { writable, derived, readable } from 'svelte/store';
import lodash from 'lodash-es'; import lodash from 'lodash-es';
import _ from 'lodash'; import { api, AssetCountByTimeBucketResponseDto } from '@api';
import moment from 'moment';
import { api, AssetCountByTimeBucketResponseDto, AssetResponseDto } from '@api';
import { AssetGridState } from '$lib/models/asset-grid-state'; import { AssetGridState } from '$lib/models/asset-grid-state';
import { calculateViewportHeightByNumberOfAsset } from '$lib/utils/viewport-utils'; import { calculateViewportHeightByNumberOfAsset } from '$lib/utils/viewport-utils';
@@ -24,7 +21,7 @@ function createAssetStore() {
_loadingBucketState = state; _loadingBucketState = state;
}); });
/** /**
* Set intial state * Set initial state
* @param viewportHeight * @param viewportHeight
* @param viewportWidth * @param viewportWidth
* @param data * @param data
@@ -37,7 +34,7 @@ function createAssetStore() {
assetGridState.set({ assetGridState.set({
viewportHeight, viewportHeight,
viewportWidth, viewportWidth,
timelineHeight: calculateViewportHeightByNumberOfAsset(data.totalCount, viewportWidth), timelineHeight: 0,
buckets: data.buckets.map((d) => ({ buckets: data.buckets.map((d) => ({
bucketDate: d.timeBucket, bucketDate: d.timeBucket,
bucketHeight: calculateViewportHeightByNumberOfAsset(d.count, viewportWidth), bucketHeight: calculateViewportHeightByNumberOfAsset(d.count, viewportWidth),
@@ -46,6 +43,12 @@ function createAssetStore() {
})), })),
assets: [] assets: []
}); });
// Update timeline height based on calculated bucket height
assetGridState.update((state) => {
state.timelineHeight = lodash.sumBy(state.buckets, (d) => d.bucketHeight);
return state;
});
}; };
const getAssetsByBucket = async (bucket: string) => { const getAssetsByBucket = async (bucket: string) => {
@@ -78,6 +81,7 @@ function createAssetStore() {
return state; return state;
}); });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) { } catch (e: any) {
if (e.name === 'CanceledError') { if (e.name === 'CanceledError') {
return; return;
@@ -110,10 +114,19 @@ function createAssetStore() {
}); });
}; };
const updateBucketHeight = (bucket: string, height: number) => { const updateBucketHeight = (bucket: string, actualBucketHeight: number) => {
assetGridState.update((state) => { assetGridState.update((state) => {
const bucketIndex = state.buckets.findIndex((b) => b.bucketDate === bucket); const bucketIndex = state.buckets.findIndex((b) => b.bucketDate === bucket);
state.buckets[bucketIndex].bucketHeight = height; // Update timeline height based on the new bucket height
const estimateBucketHeight = state.buckets[bucketIndex].bucketHeight;
if (actualBucketHeight >= estimateBucketHeight) {
state.timelineHeight += actualBucketHeight - estimateBucketHeight;
} else {
state.timelineHeight -= estimateBucketHeight - actualBucketHeight;
}
state.buckets[bucketIndex].bucketHeight = actualBucketHeight;
return state; return state;
}); });
}; };

View File

@@ -19,7 +19,8 @@ export const openWebsocketConnection = () => {
}; };
const listenToEvent = (socket: Socket) => { const listenToEvent = (socket: Socket) => {
socket.on('on_upload_success', (data) => {}); //TODO: if we are not using this, we should probably remove it?
socket.on('on_upload_success', () => undefined);
socket.on('error', (e) => { socket.on('error', (e) => {
console.log('Websocket Error', e); console.log('Websocket Error', e);

View File

@@ -1,6 +1,7 @@
export function clickOutside(node: Node) { export function clickOutside(node: Node) {
const handleClick = (event: any) => { const handleClick = (event: Event) => {
if (!node.contains(event.target)) { const targetNode = event.target as Node | null;
if (!node.contains(targetNode)) {
node.dispatchEvent(new CustomEvent('out-click')); node.dispatchEvent(new CustomEvent('out-click'));
} }
}; };

View File

@@ -27,14 +27,18 @@ export enum UploadType {
export const openFileUploadDialog = (uploadType: UploadType) => { export const openFileUploadDialog = (uploadType: UploadType) => {
try { try {
let fileSelector = document.createElement('input'); const fileSelector = document.createElement('input');
fileSelector.type = 'file'; fileSelector.type = 'file';
fileSelector.multiple = true; fileSelector.multiple = true;
fileSelector.accept = 'image/*,video/*,.heic,.heif,.dng,.3gp'; fileSelector.accept = 'image/*,video/*,.heic,.heif,.dng,.3gp';
fileSelector.onchange = async (e: any) => { fileSelector.onchange = async (e: Event) => {
const files = Array.from<File>(e.target.files); const target = e.target as HTMLInputElement;
if (!target.files) {
return;
}
const files = Array.from<File>(target.files);
if (files.length > 50) { if (files.length > 50) {
notificationController.show({ notificationController.show({
@@ -67,6 +71,7 @@ export const openFileUploadDialog = (uploadType: UploadType) => {
} }
}; };
//TODO: should probably use the @api SDK
async function fileUploader(asset: File, uploadType: UploadType) { async function fileUploader(asset: File, uploadType: UploadType) {
const assetType = asset.type.split('/')[0].toUpperCase(); const assetType = asset.type.split('/')[0].toUpperCase();
const temp = asset.name.split('.'); const temp = asset.name.split('.');
@@ -123,9 +128,10 @@ async function fileUploader(asset: File, uploadType: UploadType) {
if (status === 200) { if (status === 200) {
if (data.isExist) { if (data.isExist) {
if (uploadType === UploadType.ALBUM && data.id) { const dataId = data.id;
if (uploadType === UploadType.ALBUM && dataId) {
albumUploadAssetStore.asset.update((a) => { albumUploadAssetStore.asset.update((a) => {
return [...a, data.id!]; return [...a, dataId];
}); });
} }
return; return;
@@ -145,7 +151,7 @@ async function fileUploader(asset: File, uploadType: UploadType) {
uploadAssetsStore.addNewUploadAsset(newUploadAsset); uploadAssetsStore.addNewUploadAsset(newUploadAsset);
}; };
request.upload.onload = (event) => { request.upload.onload = () => {
setTimeout(() => { setTimeout(() => {
uploadAssetsStore.removeUploadAsset(deviceAssetId); uploadAssetsStore.removeUploadAsset(deviceAssetId);
}, 1000); }, 1000);
@@ -170,7 +176,7 @@ async function fileUploader(asset: File, uploadType: UploadType) {
}; };
// listen for `error` event // listen for `error` event
request.upload.onerror = (event) => { request.upload.onerror = () => {
uploadAssetsStore.removeUploadAsset(deviceAssetId); uploadAssetsStore.removeUploadAsset(deviceAssetId);
}; };
@@ -192,9 +198,10 @@ async function fileUploader(asset: File, uploadType: UploadType) {
console.log('error uploading file ', e); console.log('error uploading file ', e);
} }
} }
// TODO: This should have a proper type
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function handleUploadError(asset: File, respBody?: any) { function handleUploadError(asset: File, respBody?: any) {
let extraMsg = respBody ? ' ' + respBody.message : ''; const extraMsg = respBody ? ' ' + respBody.message : '';
notificationController.show({ notificationController.show({
type: NotificationType.Error, type: NotificationType.Error,

View File

@@ -4,9 +4,10 @@
*/ */
export function calculateViewportHeightByNumberOfAsset(assetCount: number, viewportWidth: number) { export function calculateViewportHeightByNumberOfAsset(assetCount: number, viewportWidth: number) {
const thumbnailHeight = 235; const thumbnailHeight = 237;
const unwrappedWidth = (3 / 2) * assetCount * thumbnailHeight * (7 / 10); // const unwrappedWidth = (3 / 2) * assetCount * thumbnailHeight * (7 / 10);
const unwrappedWidth = assetCount * thumbnailHeight;
const rows = Math.ceil(unwrappedWidth / viewportWidth); const rows = Math.ceil(unwrappedWidth / viewportWidth);
const height = rows * thumbnailHeight; const height = rows * thumbnailHeight;
return height; return height;

View File

@@ -1,4 +1,4 @@
import { serverApi, TimeGroupEnum } from '@api'; import { serverApi } from '@api';
import * as cookieParser from 'cookie'; import * as cookieParser from 'cookie';
import type { LayoutServerLoad } from './$types'; import type { LayoutServerLoad } from './$types';

View File

@@ -7,7 +7,6 @@
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 { checkAppVersion } from '$lib/utils/check-app-version';
import { page } from '$app/stores';
import { afterNavigate, beforeNavigate } from '$app/navigation'; import { afterNavigate, beforeNavigate } from '$app/navigation';
import NavigationLoadingBar from '$lib/components/shared-components/navigation-loading-bar.svelte'; import NavigationLoadingBar from '$lib/components/shared-components/navigation-loading-bar.svelte';
import NotificationList from '$lib/components/shared-components/notification/notification-list.svelte'; import NotificationList from '$lib/components/shared-components/notification/notification-list.svelte';

View File

@@ -1,5 +1,5 @@
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import { serverApi, UserResponseDto } from '@api'; import { serverApi } from '@api';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ parent }) => { export const load: PageServerLoad = async ({ parent }) => {

View File

@@ -1,6 +1,6 @@
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
import { AlbumResponseDto, serverApi } from '@api'; import { serverApi } from '@api';
export const load: PageServerLoad = async ({ parent }) => { export const load: PageServerLoad = async ({ parent }) => {
try { try {

View File

@@ -9,7 +9,7 @@
import NavigationBar from '$lib/components/shared-components/navigation-bar.svelte'; import NavigationBar from '$lib/components/shared-components/navigation-bar.svelte';
import SideBar from '$lib/components/shared-components/side-bar/side-bar.svelte'; import SideBar from '$lib/components/shared-components/side-bar/side-bar.svelte';
import PlusBoxOutline from 'svelte-material-icons/PlusBoxOutline.svelte'; import PlusBoxOutline from 'svelte-material-icons/PlusBoxOutline.svelte';
import { useAlbums } from './albums-bloc'; import { useAlbums } from './albums.bloc';
export let data: PageData; export let data: PageData;

View File

@@ -1,5 +1,5 @@
import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals'; import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals';
import { useAlbums } from '../albums-bloc'; import { useAlbums } from '../albums.bloc';
import { api, CreateAlbumDto } from '@api'; import { api, CreateAlbumDto } from '@api';
import { import {
notificationController, notificationController,

View File

@@ -6,8 +6,6 @@
import type { PageData } from './$types'; import type { PageData } from './$types';
import { openFileUploadDialog, UploadType } from '$lib/utils/file-uploader'; import { openFileUploadDialog, UploadType } from '$lib/utils/file-uploader';
import { onMount } from 'svelte';
import { closeWebsocketConnection, openWebsocketConnection } from '$lib/stores/websocket';
import { import {
assetInteractionStore, assetInteractionStore,
isMultiSelectStoreState, isMultiSelectStoreState,

View File

@@ -1,6 +1,6 @@
import preprocess from 'svelte-preprocess'; import preprocess from 'svelte-preprocess';
import adapter from '@sveltejs/adapter-node'; import adapter from '@sveltejs/adapter-node';
import path from 'path';
/** @type {import('@sveltejs/kit').Config} */ /** @type {import('@sveltejs/kit').Config} */
const config = { const config = {
preprocess: preprocess(), preprocess: preprocess(),