Compare commits
21 Commits
v1.28.0_38
...
v1.28.2_40
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31739aca02 | ||
|
|
8f2e7b6f65 | ||
|
|
4ed647c43d | ||
|
|
f88ff4fb5c | ||
|
|
cc4881d633 | ||
|
|
d856b35afc | ||
|
|
b6d025da09 | ||
|
|
cc79ff1ca3 | ||
|
|
131aa2b6be | ||
|
|
02a6b73122 | ||
|
|
d87366c095 | ||
|
|
4f7a3afbfc | ||
|
|
6725954b70 | ||
|
|
4fe535e5e8 | ||
|
|
aed94bfc4c | ||
|
|
de996c0a81 | ||
|
|
1a39aa4da5 | ||
|
|
1f4ba73da7 | ||
|
|
836b174d33 | ||
|
|
6b702b13e4 | ||
|
|
f476bd985b |
17
.github/workflows/test.yml
vendored
17
.github/workflows/test.yml
vendored
@@ -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
|
||||||
|
|||||||
@@ -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
32
dev-setup.md
Normal 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`
|
||||||
25
install.sh
25
install.sh
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
* Improve Android background service reliability
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
* Fix background service cannot run in release build
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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: () {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsNotEmpty, IsOptional } from 'class-validator';
|
import { IsOptional } from 'class-validator';
|
||||||
|
|
||||||
export class UpdateAlbumDto {
|
export class UpdateAlbumDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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]));
|
||||||
|
|||||||
@@ -16,5 +16,8 @@ module.exports = {
|
|||||||
browser: true,
|
browser: true,
|
||||||
es2017: true,
|
es2017: true,
|
||||||
node: true
|
node: true
|
||||||
|
},
|
||||||
|
globals: {
|
||||||
|
NodeJS: true
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
338
web/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
|||||||
103
web/src/app.css
103
web/src/app.css
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
8
web/src/lib/__mocks__/jsdom-url.mock.ts
Normal file
8
web/src/lib/__mocks__/jsdom-url.mock.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
const createObjectURLMock = jest.fn();
|
||||||
|
|
||||||
|
Object.defineProperty(URL, 'createObjectURL', {
|
||||||
|
writable: true,
|
||||||
|
value: createObjectURLMock
|
||||||
|
});
|
||||||
|
|
||||||
|
export { createObjectURLMock };
|
||||||
144
web/src/lib/components/album-page/__tests__/album-card.spec.ts
Normal file
144
web/src/lib/components/album-page/__tests__/album-card.spec.ts
Normal 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 } })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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'))
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
if (browser) {
|
if (browser) {
|
||||||
window.onscroll = function () {};
|
window.onscroll = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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?
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
10
web/src/lib/stores/album-asset-selection.store.ts
Normal file
10
web/src/lib/stores/album-asset-selection.store.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
|
||||||
|
function createAlbumAssetSelectionStore() {
|
||||||
|
const isAlbumAssetSelectionOpen = writable<boolean>(false);
|
||||||
|
return {
|
||||||
|
isAlbumAssetSelectionOpen
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const albumAssetSelectionStore = createAlbumAssetSelectionStore();
|
||||||
@@ -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;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
Reference in New Issue
Block a user