Compare commits
17 Commits
v1.28.0_38
...
v1.28.1_39
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||
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:
|
||||
name: Run unit test suites
|
||||
server-unit-tests:
|
||||
name: Run server unit test suites and checks
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -27,4 +27,15 @@ jobs:
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- 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)
|
||||
- [Screenshots](#screenshots)
|
||||
- [Installation](#installation)
|
||||
- [Update](#update)
|
||||
- [Mobile App](#-mobile-app)
|
||||
- [Development](#development)
|
||||
- [Support](#support)
|
||||
@@ -172,6 +173,14 @@ wget -O .env https://raw.githubusercontent.com/immich-app/immich/main/docker/.en
|
||||
|
||||
<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
|
||||
|
||||
| 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`
|
||||
@@ -12,6 +12,7 @@
|
||||
</intent-filter>
|
||||
|
||||
</activity>
|
||||
<service android:name=".AppClearedService" android:stopWithTask="false" />
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<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
|
||||
|
||||
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.plugin.common.BinaryMessenger
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
@@ -44,30 +39,30 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
val ctx = context!!
|
||||
when(call.method) {
|
||||
"initialize" -> { // needs to be called prior to any other method
|
||||
"enable" -> {
|
||||
val args = call.arguments<ArrayList<*>>()!!
|
||||
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)
|
||||
}
|
||||
"start" -> {
|
||||
"configure" -> {
|
||||
val args = call.arguments<ArrayList<*>>()!!
|
||||
val immediate = args.get(0) as Boolean
|
||||
val keepExisting = args.get(1) as Boolean
|
||||
val requireUnmeteredNetwork = args.get(2) as Boolean
|
||||
val requireCharging = args.get(3) as Boolean
|
||||
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)
|
||||
val requireUnmeteredNetwork = args.get(0) as Boolean
|
||||
val requireCharging = args.get(1) as Boolean
|
||||
ContentObserverWorker.configureWork(ctx, requireUnmeteredNetwork, requireCharging)
|
||||
result.success(true)
|
||||
}
|
||||
"stop" -> {
|
||||
"disable" -> {
|
||||
ContentObserverWorker.disable(ctx)
|
||||
BackupWorker.stopWork(ctx)
|
||||
result.success(true)
|
||||
}
|
||||
"isEnabled" -> {
|
||||
result.success(BackupWorker.isEnabled(ctx))
|
||||
result.success(ContentObserverWorker.isEnabled(ctx))
|
||||
}
|
||||
"isIgnoringBatteryOptimizations" -> {
|
||||
result.success(BackupWorker.isIgnoringBatteryOptimizations(ctx))
|
||||
|
||||
@@ -8,17 +8,12 @@ import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.PowerManager
|
||||
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 androidx.annotation.RequiresApi
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.concurrent.futures.ResolvableFuture
|
||||
import androidx.work.BackoffPolicy
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.Data
|
||||
import androidx.work.ForegroundInfo
|
||||
import androidx.work.ListenableWorker
|
||||
import androidx.work.NetworkType
|
||||
@@ -26,6 +21,7 @@ import androidx.work.WorkerParameters
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequest
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkInfo
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
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
|
||||
* `background.service.dart` to run the actual backup logic.
|
||||
* 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.
|
||||
* 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.
|
||||
* i.e. battery is not low and optionally Wifi and charging are active.
|
||||
*/
|
||||
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 val notificationManager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
private val isIgnoringBatteryOptimizations = isIgnoringBatteryOptimizations(applicationContext)
|
||||
private var timeBackupStarted: Long = 0L
|
||||
|
||||
override fun startWork(): ListenableFuture<ListenableWorker.Result> {
|
||||
|
||||
Log.d(TAG, "startWork")
|
||||
|
||||
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()) {
|
||||
flutterLoader.startInitialization(ctx)
|
||||
@@ -73,14 +61,16 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
||||
// Create a Notification channel if necessary
|
||||
createChannel()
|
||||
}
|
||||
val title = ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||
.getString(SHARED_PREF_NOTIFICATION_TITLE, NOTIFICATION_DEFAULT_TITLE)!!
|
||||
if (isIgnoringBatteryOptimizations) {
|
||||
// normal background services can only up to 10 minutes
|
||||
// foreground services are allowed to run indefinitely
|
||||
// requires battery optimizations to be disabled (either manually by 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))
|
||||
} else {
|
||||
showBackgroundInfo(title)
|
||||
}
|
||||
engine = FlutterEngine(ctx)
|
||||
|
||||
@@ -115,6 +105,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
||||
}
|
||||
|
||||
override fun onStopped() {
|
||||
Log.d(TAG, "onStopped")
|
||||
// called when the system has to stop this worker because constraints are
|
||||
// no longer met or the system needs resources for more important tasks
|
||||
Handler(Looper.getMainLooper()).postAtFrontOfQueue {
|
||||
@@ -130,24 +121,18 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
||||
|
||||
private fun stopEngine(result: Result?) {
|
||||
if (result != null) {
|
||||
Log.d(TAG, "stopEngine result=${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 = null
|
||||
clearBackgroundNotification()
|
||||
}
|
||||
|
||||
override fun onMethodCall(call: MethodCall, r: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"initialized" ->
|
||||
"initialized" -> {
|
||||
timeBackupStarted = SystemClock.uptimeMillis()
|
||||
backgroundChannel.invokeMethod(
|
||||
"onAssetsChanged",
|
||||
null,
|
||||
@@ -163,25 +148,18 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
||||
override fun success(receivedResult: Any?) {
|
||||
val success = receivedResult as Boolean
|
||||
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" -> {
|
||||
val args = call.arguments<ArrayList<*>>()!!
|
||||
val title = args.get(0) as String
|
||||
val content = args.get(1) as String
|
||||
if (isIgnoringBatteryOptimizations) {
|
||||
setForegroundAsync(createForegroundInfo(title, content))
|
||||
} else {
|
||||
showBackgroundInfo(title, content)
|
||||
}
|
||||
}
|
||||
"showError" -> {
|
||||
@@ -192,6 +170,14 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
||||
showError(title, content, individualTag)
|
||||
}
|
||||
"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()
|
||||
}
|
||||
}
|
||||
@@ -211,6 +197,22 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
||||
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 {
|
||||
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID)
|
||||
.setContentTitle(title)
|
||||
@@ -233,89 +235,61 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
||||
companion object {
|
||||
const val SHARED_PREF_NAME = "immichBackgroundService"
|
||||
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_LAST_CHANGE = "lastChange"
|
||||
|
||||
private const val TASK_NAME = "immich/photoListener"
|
||||
private const val DATA_KEY_UNMETERED = "unmetered"
|
||||
private const val DATA_KEY_CHARGING = "charging"
|
||||
private const val DATA_KEY_RETRIES = "retries"
|
||||
private const val TASK_NAME_BACKUP = "immich/BackupWorker"
|
||||
private const val NOTIFICATION_CHANNEL_ID = "immich/backgroundService"
|
||||
private const val NOTIFICATION_CHANNEL_ERROR_ID = "immich/backgroundServiceError"
|
||||
private const val NOTIFICATION_DEFAULT_TITLE = "Immich"
|
||||
private const val NOTIFICATION_ID = 1
|
||||
private const val NOTIFICATION_ERROR_ID = 2
|
||||
private const val ONE_MINUTE: Long = 60000
|
||||
private const val ONE_MINUTE = 60000L
|
||||
|
||||
/**
|
||||
* Enqueues the `BackupWorker` to run when all 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)
|
||||
* Enqueues the BackupWorker to run once the constraints are met
|
||||
*/
|
||||
fun startWork(context: Context,
|
||||
immediate: Boolean = false,
|
||||
keepExisting: Boolean = false,
|
||||
requireUnmeteredNetwork: Boolean = false,
|
||||
requireCharging: Boolean = false) {
|
||||
context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||
.edit().putBoolean(SHARED_PREF_SERVICE_ENABLED, true).apply()
|
||||
enqueueMoreWork(context, immediate, keepExisting, requireUnmeteredNetwork, requireCharging)
|
||||
fun enqueueBackupWorker(context: Context,
|
||||
requireWifi: Boolean = false,
|
||||
requireCharging: Boolean = false,
|
||||
delayMilliseconds: Long = 0L) {
|
||||
val workRequest = buildWorkRequest(requireWifi, requireCharging, delayMilliseconds)
|
||||
WorkManager.getInstance(context).enqueueUniqueWork(TASK_NAME_BACKUP, ExistingWorkPolicy.KEEP, workRequest)
|
||||
Log.d(TAG, "enqueueBackupWorker: BackupWorker enqueued")
|
||||
}
|
||||
|
||||
private fun enqueueMoreWork(context: Context,
|
||||
immediate: Boolean = false,
|
||||
keepExisting: Boolean = false,
|
||||
requireUnmeteredNetwork: Boolean = false,
|
||||
requireCharging: Boolean = false,
|
||||
initialDelayInMs: Long = 0,
|
||||
retries: Int = 0) {
|
||||
if (!isEnabled(context)) {
|
||||
return
|
||||
/**
|
||||
* Updates the constraints of an already enqueued BackupWorker
|
||||
*/
|
||||
fun updateBackupWorker(context: Context,
|
||||
requireWifi: Boolean = false,
|
||||
requireCharging: Boolean = false) {
|
||||
try {
|
||||
val wm = WorkManager.getInstance(context)
|
||||
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
|
||||
*/
|
||||
fun stopWork(context: Context) {
|
||||
context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||
.edit().putBoolean(SHARED_PREF_SERVICE_ENABLED, false).apply()
|
||||
WorkManager.getInstance(context).cancelUniqueWork(TASK_NAME)
|
||||
WorkManager.getInstance(context).cancelUniqueWork(TASK_NAME_BACKUP)
|
||||
Log.d(TAG, "stopWork: BackupWorker cancelled")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -330,12 +304,21 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if the user has enabled the background backup service
|
||||
*/
|
||||
fun isEnabled(ctx: Context): Boolean {
|
||||
return ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||
.getBoolean(SHARED_PREF_SERVICE_ENABLED, false)
|
||||
private fun buildWorkRequest(requireWifi: Boolean = false,
|
||||
requireCharging: Boolean = false,
|
||||
delayMilliseconds: Long = 0L): OneTimeWorkRequest {
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(if (requireWifi) NetworkType.UNMETERED else NetworkType.CONNECTED)
|
||||
.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()
|
||||
|
||||
@@ -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.engine.FlutterEngine
|
||||
import android.os.Bundle
|
||||
import android.content.Intent
|
||||
|
||||
class MainActivity: FlutterActivity() {
|
||||
|
||||
@@ -10,4 +12,9 @@ class MainActivity: FlutterActivity() {
|
||||
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',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 38,
|
||||
"android.injected.version.name" => "1.28.0",
|
||||
"android.injected.version.code" => 39,
|
||||
"android.injected.version.name" => "1.28.1",
|
||||
}
|
||||
)
|
||||
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
|
||||
@@ -19,7 +19,7 @@ platform :ios do
|
||||
desc "iOS Beta"
|
||||
lane :beta do
|
||||
increment_version_number(
|
||||
version_number: "1.28.0"
|
||||
version_number: "1.28.1"
|
||||
)
|
||||
increment_build_number(
|
||||
build_number: latest_testflight_build_number + 1,
|
||||
|
||||
@@ -4,7 +4,6 @@ import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
import 'dart:ui' show IsolateNameServer, PluginUtilities;
|
||||
import 'package:cancellation_token_http/http.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
@@ -33,7 +32,6 @@ class BackgroundService {
|
||||
MethodChannel('immich/foregroundChannel');
|
||||
static const MethodChannel _backgroundChannel =
|
||||
MethodChannel('immich/backgroundChannel');
|
||||
bool _isForegroundInitialized = false;
|
||||
bool _isBackgroundInitialized = false;
|
||||
CancellationToken? _cancellationToken;
|
||||
bool _canceledBySystem = false;
|
||||
@@ -43,32 +41,34 @@ class BackgroundService {
|
||||
ReceivePort? _rp;
|
||||
bool _errorGracePeriodExceeded = true;
|
||||
|
||||
bool get isForegroundInitialized {
|
||||
return _isForegroundInitialized;
|
||||
}
|
||||
|
||||
bool get 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
|
||||
Future<bool> resumeServiceIfEnabled() async {
|
||||
return await isBackgroundBackupEnabled() &&
|
||||
await startService(keepExisting: true);
|
||||
return await isBackgroundBackupEnabled() && await enableService();
|
||||
}
|
||||
|
||||
/// Enqueues the background service
|
||||
Future<bool> startService({
|
||||
bool immediate = false,
|
||||
bool keepExisting = false,
|
||||
Future<bool> enableService({bool immediate = false}) async {
|
||||
if (!Platform.isAndroid) {
|
||||
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 requireCharging = false,
|
||||
}) async {
|
||||
@@ -76,14 +76,9 @@ class BackgroundService {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
if (!_isForegroundInitialized) {
|
||||
await _initialize();
|
||||
}
|
||||
final String title =
|
||||
"backup_background_service_default_notification".tr();
|
||||
final bool ok = await _foregroundChannel.invokeMethod(
|
||||
'start',
|
||||
[immediate, keepExisting, requireUnmetered, requireCharging, title],
|
||||
'configure',
|
||||
[requireUnmetered, requireCharging],
|
||||
);
|
||||
return ok;
|
||||
} catch (error) {
|
||||
@@ -92,15 +87,12 @@ class BackgroundService {
|
||||
}
|
||||
|
||||
/// Cancels the background service (if currently running) and removes it from work queue
|
||||
Future<bool> stopService() async {
|
||||
Future<bool> disableService() async {
|
||||
if (!Platform.isAndroid) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
if (!_isForegroundInitialized) {
|
||||
await _initialize();
|
||||
}
|
||||
final ok = await _foregroundChannel.invokeMethod('stop');
|
||||
final ok = await _foregroundChannel.invokeMethod('disable');
|
||||
return ok;
|
||||
} catch (error) {
|
||||
return false;
|
||||
@@ -113,9 +105,6 @@ class BackgroundService {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
if (!_isForegroundInitialized) {
|
||||
await _initialize();
|
||||
}
|
||||
return await _foregroundChannel.invokeMethod("isEnabled");
|
||||
} catch (error) {
|
||||
return false;
|
||||
@@ -128,9 +117,6 @@ class BackgroundService {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
if (!_isForegroundInitialized) {
|
||||
await _initialize();
|
||||
}
|
||||
return await _foregroundChannel
|
||||
.invokeMethod('isIgnoringBatteryOptimizations');
|
||||
} catch (error) {
|
||||
@@ -289,18 +275,11 @@ class BackgroundService {
|
||||
try {
|
||||
final bool hasAccess = await acquireLock();
|
||||
if (!hasAccess) {
|
||||
debugPrint("[_callHandler] could acquire lock, exiting");
|
||||
debugPrint("[_callHandler] could not acquire lock, exiting");
|
||||
return false;
|
||||
}
|
||||
await translationsLoaded;
|
||||
final bool ok = await _onAssetsChanged();
|
||||
if (ok) {
|
||||
Hive.box(backgroundBackupInfoBox).delete(backupFailedSince);
|
||||
} else if (Hive.box(backgroundBackupInfoBox).get(backupFailedSince) ==
|
||||
null) {
|
||||
Hive.box(backgroundBackupInfoBox)
|
||||
.put(backupFailedSince, DateTime.now());
|
||||
}
|
||||
return ok;
|
||||
} catch (error) {
|
||||
debugPrint(error.toString());
|
||||
@@ -343,6 +322,29 @@ class BackgroundService {
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
if (_canceledBySystem) {
|
||||
@@ -382,10 +384,6 @@ class BackgroundService {
|
||||
);
|
||||
if (ok) {
|
||||
_clearErrorNotifications();
|
||||
await box.put(
|
||||
backupInfoKey,
|
||||
backupAlbumInfo,
|
||||
);
|
||||
} else {
|
||||
_showErrorNotification(
|
||||
title: "backup_background_service_error_title".tr(),
|
||||
|
||||
@@ -131,13 +131,15 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
);
|
||||
|
||||
if (state.backgroundBackup) {
|
||||
bool success = true;
|
||||
if (!wasEnabled) {
|
||||
if (!await _backgroundService.isIgnoringBatteryOptimizations()) {
|
||||
onBatteryInfo();
|
||||
}
|
||||
success &= await _backgroundService.enableService(immediate: true);
|
||||
}
|
||||
final bool success = await _backgroundService.stopService() &&
|
||||
await _backgroundService.startService(
|
||||
success &= success &&
|
||||
await _backgroundService.configureService(
|
||||
requireUnmetered: state.backupRequireWifi,
|
||||
requireCharging: state.backupRequireCharging,
|
||||
);
|
||||
@@ -155,7 +157,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
onError("backup_controller_page_background_configure_error");
|
||||
}
|
||||
} else {
|
||||
final bool success = await _backgroundService.stopService();
|
||||
final bool success = await _backgroundService.disableService();
|
||||
if (!success) {
|
||||
state = state.copyWith(backgroundBackup: wasEnabled);
|
||||
onError("backup_controller_page_background_configure_error");
|
||||
|
||||
@@ -21,7 +21,7 @@ class ImmichSliverAppBar extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final BackUpState backupState = ref.watch(backupProvider);
|
||||
bool isEnableAutoBackup =
|
||||
bool isEnableAutoBackup = backupState.backgroundBackup ||
|
||||
ref.watch(authenticationProvider).deviceInfo.isAutoBackup;
|
||||
final ServerInfoState serverInfoState = ref.watch(serverInfoProvider);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ name: immich_mobile
|
||||
description: Immich - selfhosted backup media file on mobile phone
|
||||
|
||||
publish_to: "none"
|
||||
version: 1.28.0+38
|
||||
version: 1.28.1+39
|
||||
|
||||
environment:
|
||||
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 { UpdateAlbumDto } from './dto/update-album.dto';
|
||||
import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
|
||||
import { AlbumResponseDto } from './response-dto/album-response.dto';
|
||||
|
||||
export interface IAlbumRepository {
|
||||
create(ownerId: string, createAlbumDto: CreateAlbumDto): Promise<AlbumEntity>;
|
||||
@@ -165,7 +164,7 @@ export class AlbumRepository implements IAlbumRepository {
|
||||
}
|
||||
|
||||
async getListByAssetId(userId: string, assetId: string): Promise<AlbumEntity[]> {
|
||||
let query = this.albumRepository.createQueryBuilder('album');
|
||||
const query = this.albumRepository.createQueryBuilder('album');
|
||||
|
||||
const albums = await query
|
||||
.where('album.ownerId = :ownerId', { ownerId: userId })
|
||||
@@ -190,7 +189,7 @@ export class AlbumRepository implements IAlbumRepository {
|
||||
}
|
||||
|
||||
async get(albumId: string): Promise<AlbumEntity | undefined> {
|
||||
let query = this.albumRepository.createQueryBuilder('album');
|
||||
const query = this.albumRepository.createQueryBuilder('album');
|
||||
|
||||
const album = await query
|
||||
.where('album.id = :albumId', { albumId })
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
ParseUUIDPipe,
|
||||
Put,
|
||||
Query,
|
||||
Header,
|
||||
} from '@nestjs/common';
|
||||
import { ParseMeUUIDPipe } from '../validation/parse-me-uuid-pipe';
|
||||
import { AlbumService } from './album.service';
|
||||
|
||||
@@ -4,7 +4,6 @@ import { AuthUserDto } from '../../decorators/auth-user.decorator';
|
||||
import { BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
import { AlbumEntity } from '@app/database/entities/album.entity';
|
||||
import { AlbumResponseDto } from './response-dto/album-response.dto';
|
||||
import { AssetAlbumEntity } from '@app/database/entities/asset-album.entity';
|
||||
|
||||
describe('Album service', () => {
|
||||
let sut: AlbumService;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsNotEmpty, IsOptional } from 'class-validator';
|
||||
import { IsOptional } from 'class-validator';
|
||||
|
||||
export class UpdateAlbumDto {
|
||||
@IsOptional()
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
HttpCode,
|
||||
BadRequestException,
|
||||
UploadedFile,
|
||||
Header,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
|
||||
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 { assetUploadedProcessorName } from '@app/job/constants/job-name.constant';
|
||||
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 { CuratedLocationsResponseDto } from './response-dto/curated-locations-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 { AssetService } from './asset.service';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AssetEntity, AssetType } from '@app/database/entities/asset.entity';
|
||||
import { CreateAssetDto } from './dto/create-asset.dto';
|
||||
import { AssetEntity } from '@app/database/entities/asset.entity';
|
||||
|
||||
describe('AssetService', () => {
|
||||
let sui: AssetService;
|
||||
@@ -15,39 +14,39 @@ describe('AssetService', () => {
|
||||
email: 'auth@test.com',
|
||||
});
|
||||
|
||||
const _getCreateAssetDto = (): CreateAssetDto => {
|
||||
const createAssetDto = new CreateAssetDto();
|
||||
createAssetDto.deviceAssetId = 'deviceAssetId';
|
||||
createAssetDto.deviceId = 'deviceId';
|
||||
createAssetDto.assetType = AssetType.OTHER;
|
||||
createAssetDto.createdAt = '2022-06-19T23:41:36.910Z';
|
||||
createAssetDto.modifiedAt = '2022-06-19T23:41:36.910Z';
|
||||
createAssetDto.isFavorite = false;
|
||||
createAssetDto.duration = '0:00:00.000000';
|
||||
// const _getCreateAssetDto = (): CreateAssetDto => {
|
||||
// const createAssetDto = new CreateAssetDto();
|
||||
// createAssetDto.deviceAssetId = 'deviceAssetId';
|
||||
// createAssetDto.deviceId = 'deviceId';
|
||||
// createAssetDto.assetType = AssetType.OTHER;
|
||||
// createAssetDto.createdAt = '2022-06-19T23:41:36.910Z';
|
||||
// createAssetDto.modifiedAt = '2022-06-19T23:41:36.910Z';
|
||||
// createAssetDto.isFavorite = false;
|
||||
// createAssetDto.duration = '0:00:00.000000';
|
||||
|
||||
return createAssetDto;
|
||||
};
|
||||
const _getAsset = () => {
|
||||
const assetEntity = new AssetEntity();
|
||||
// return createAssetDto;
|
||||
// };
|
||||
// const _getAsset = () => {
|
||||
// const assetEntity = new AssetEntity();
|
||||
|
||||
assetEntity.id = 'e8edabfd-7d8a-45d0-9d61-7c7ca60f2c67';
|
||||
assetEntity.userId = '3ea54709-e168-42b7-90b0-a0dfe8a7ecbd';
|
||||
assetEntity.deviceAssetId = '4967046344801';
|
||||
assetEntity.deviceId = '116766fd-2ef2-52dc-a3ef-149988997291';
|
||||
assetEntity.type = AssetType.VIDEO;
|
||||
assetEntity.originalPath =
|
||||
'upload/3ea54709-e168-42b7-90b0-a0dfe8a7ecbd/original/116766fd-2ef2-52dc-a3ef-149988997291/51c97f95-244f-462d-bdf0-e1dc19913516.jpg';
|
||||
assetEntity.resizePath = '';
|
||||
assetEntity.createdAt = '2022-06-19T23:41:36.910Z';
|
||||
assetEntity.modifiedAt = '2022-06-19T23:41:36.910Z';
|
||||
assetEntity.isFavorite = false;
|
||||
assetEntity.mimeType = 'image/jpeg';
|
||||
assetEntity.webpPath = '';
|
||||
assetEntity.encodedVideoPath = '';
|
||||
assetEntity.duration = '0:00:00.000000';
|
||||
// assetEntity.id = 'e8edabfd-7d8a-45d0-9d61-7c7ca60f2c67';
|
||||
// assetEntity.userId = '3ea54709-e168-42b7-90b0-a0dfe8a7ecbd';
|
||||
// assetEntity.deviceAssetId = '4967046344801';
|
||||
// assetEntity.deviceId = '116766fd-2ef2-52dc-a3ef-149988997291';
|
||||
// assetEntity.type = AssetType.VIDEO;
|
||||
// assetEntity.originalPath =
|
||||
// 'upload/3ea54709-e168-42b7-90b0-a0dfe8a7ecbd/original/116766fd-2ef2-52dc-a3ef-149988997291/51c97f95-244f-462d-bdf0-e1dc19913516.jpg';
|
||||
// assetEntity.resizePath = '';
|
||||
// assetEntity.createdAt = '2022-06-19T23:41:36.910Z';
|
||||
// assetEntity.modifiedAt = '2022-06-19T23:41:36.910Z';
|
||||
// assetEntity.isFavorite = false;
|
||||
// assetEntity.mimeType = 'image/jpeg';
|
||||
// assetEntity.webpPath = '';
|
||||
// assetEntity.encodedVideoPath = '';
|
||||
// assetEntity.duration = '0:00:00.000000';
|
||||
|
||||
return assetEntity;
|
||||
};
|
||||
// return assetEntity;
|
||||
// };
|
||||
|
||||
beforeAll(() => {
|
||||
assetRepositoryMock = {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsBoolean, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
import { IsOptional } from 'class-validator';
|
||||
|
||||
export enum GetAssetThumbnailFormatEnum {
|
||||
JPEG = 'JPEG',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import { IsBoolean, IsBooleanString, IsNotEmpty, IsOptional } from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsBoolean, IsNotEmpty, IsOptional } from 'class-validator';
|
||||
|
||||
export class ServeFileDto {
|
||||
@IsNotEmpty()
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import { Body, Controller, Post, Res, UseGuards, ValidationPipe } from '@nestjs/common';
|
||||
import {
|
||||
ApiBadRequestResponse,
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiCreatedResponse,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { ApiBadRequestResponse, ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
|
||||
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { UserEntity } from '@app/database/entities/user.entity';
|
||||
import { ApiProperty, ApiResponseProperty } from '@nestjs/swagger';
|
||||
import { ApiResponseProperty } from '@nestjs/swagger';
|
||||
|
||||
export class LoginResponseDto {
|
||||
@ApiResponseProperty()
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { DeviceType } from '@app/database/entities/device-info.entity';
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsOptional } from 'class-validator';
|
||||
import { CreateDeviceInfoDto } from './create-device-info.dto';
|
||||
|
||||
export class UpdateDeviceInfoDto {
|
||||
@IsNotEmpty()
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ServerInfoService } from './server-info.service';
|
||||
import { serverVersion } from '../../constants/server_version.constant';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { ServerPingResponse } from './response-dto/server-ping-response.dto';
|
||||
import { ServerVersionReponseDto } from './response-dto/server-version-response.dto';
|
||||
import { ServerInfoResponseDto } from './response-dto/server-info-response.dto';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
@ApiTags('Server Info')
|
||||
@Controller('server-info')
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
UploadedFile,
|
||||
Response,
|
||||
Request,
|
||||
StreamableFile,
|
||||
ParseBoolPipe,
|
||||
} from '@nestjs/common';
|
||||
import { UserService } from './user.service';
|
||||
|
||||
@@ -15,7 +15,6 @@ import { AppController } from './app.controller';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.module';
|
||||
import { DatabaseModule } from '@app/database';
|
||||
import { AppLoggerMiddleware } from './middlewares/app-logger.middleware';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
||||
@@ -11,6 +11,6 @@ export interface IServerVersion {
|
||||
export const serverVersion: IServerVersion = {
|
||||
major: 1,
|
||||
minor: 28,
|
||||
patch: 0,
|
||||
build: 0,
|
||||
patch: 1,
|
||||
build: 39,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { dataSource } from '@app/database/config/database.config';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
|
||||
@@ -2,7 +2,6 @@ import { InjectQueue } from '@nestjs/bull/dist/decorators';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Queue } from 'bull';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { AssetEntity } from '@app/database/entities/asset.entity';
|
||||
import { AssetResponseDto } from '../../api-v1/asset/response-dto/asset-response.dto';
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -18,7 +18,7 @@ export class GenerateChecksumProcessor {
|
||||
@Process()
|
||||
async generateChecksum() {
|
||||
let hasNext = true;
|
||||
let pageSize = 200;
|
||||
const pageSize = 200;
|
||||
|
||||
while (hasNext) {
|
||||
const assets = await this.assetRepository.find({
|
||||
|
||||
@@ -21,7 +21,6 @@ import axios from 'axios';
|
||||
import { Job } from 'bull';
|
||||
import exifr from 'exifr';
|
||||
import ffmpeg from 'fluent-ffmpeg';
|
||||
import { readFile } from 'fs/promises';
|
||||
import path from 'path';
|
||||
import sharp from 'sharp';
|
||||
import { Repository } from 'typeorm/repository/Repository';
|
||||
@@ -330,7 +329,7 @@ export class MetadataExtractionProcessor {
|
||||
}
|
||||
|
||||
if (stream.r_frame_rate) {
|
||||
let fpsParts = stream.r_frame_rate.split('/');
|
||||
const fpsParts = stream.r_frame_rate.split('/');
|
||||
|
||||
if (fpsParts.length === 2) {
|
||||
newExif.fps = Math.round(parseInt(fpsParts[0]) / parseInt(fpsParts[1]));
|
||||
|
||||
@@ -16,5 +16,8 @@ module.exports = {
|
||||
browser: true,
|
||||
es2017: true,
|
||||
node: true
|
||||
},
|
||||
globals: {
|
||||
NodeJS: true
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6,6 +6,9 @@ node_modules
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
src/api/open-api
|
||||
*.md
|
||||
*.json
|
||||
|
||||
# Ignore files for PNPM, NPM and YARN
|
||||
pnpm-lock.yaml
|
||||
|
||||
@@ -178,14 +178,17 @@ export default {
|
||||
|
||||
// A map from regular expressions to paths to transformers
|
||||
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
|
||||
// transformIgnorePatterns: [
|
||||
// "/node_modules/",
|
||||
// "\\.pnp\\.[^\\/]+$"
|
||||
// ],
|
||||
transformIgnorePatterns: ['/node_modules/(?!svelte-material-icons).*/', '\\.pnp\\.[^\\/]+$']
|
||||
|
||||
// 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,
|
||||
|
||||
338
web/package-lock.json
generated
338
web/package-lock.json
generated
@@ -25,6 +25,8 @@
|
||||
"@sveltejs/adapter-auto": "next",
|
||||
"@sveltejs/adapter-node": "next",
|
||||
"@sveltejs/kit": "next",
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/svelte": "^3.2.1",
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"@types/cookie": "^0.4.1",
|
||||
"@types/fluent-ffmpeg": "^2.1.20",
|
||||
@@ -48,13 +50,19 @@
|
||||
"svelte": "^3.44.0",
|
||||
"svelte-check": "^2.7.1",
|
||||
"svelte-jester": "^2.3.2",
|
||||
"svelte-preprocess": "^4.10.6",
|
||||
"svelte-preprocess": "^4.10.7",
|
||||
"tailwindcss": "^3.0.24",
|
||||
"tslib": "^2.3.1",
|
||||
"typescript": "^4.7.4",
|
||||
"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": {
|
||||
"version": "2.2.0",
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
|
||||
@@ -2635,6 +2744,12 @@
|
||||
"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": {
|
||||
"version": "7.1.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.19.tgz",
|
||||
@@ -2739,6 +2854,16 @@
|
||||
"@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": {
|
||||
"version": "20.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.0.tgz",
|
||||
@@ -2823,6 +2948,15 @@
|
||||
"integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==",
|
||||
"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": {
|
||||
"version": "4.0.2",
|
||||
"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==",
|
||||
"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": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
|
||||
@@ -3866,6 +4009,12 @@
|
||||
"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": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
@@ -4133,6 +4282,12 @@
|
||||
"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": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz",
|
||||
@@ -5601,6 +5756,15 @@
|
||||
"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": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||
@@ -6660,6 +6824,15 @@
|
||||
"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": {
|
||||
"version": "0.26.2",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.26.2.tgz",
|
||||
@@ -7621,6 +7794,19 @@
|
||||
"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": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz",
|
||||
@@ -9091,6 +9277,12 @@
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz",
|
||||
@@ -10964,12 +11156,97 @@
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
|
||||
"integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==",
|
||||
"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": {
|
||||
"version": "7.1.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.19.tgz",
|
||||
@@ -11074,6 +11351,16 @@
|
||||
"@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": {
|
||||
"version": "20.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.0.tgz",
|
||||
@@ -11157,6 +11444,15 @@
|
||||
"integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==",
|
||||
"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": {
|
||||
"version": "4.0.2",
|
||||
"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==",
|
||||
"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": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
|
||||
@@ -11908,6 +12210,12 @@
|
||||
"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": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
@@ -12111,6 +12419,12 @@
|
||||
"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": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz",
|
||||
@@ -13102,6 +13416,12 @@
|
||||
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
|
||||
"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": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||
@@ -13909,6 +14229,12 @@
|
||||
"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": {
|
||||
"version": "0.26.2",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.26.2.tgz",
|
||||
@@ -14562,6 +14888,16 @@
|
||||
"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": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz",
|
||||
|
||||
@@ -6,10 +6,14 @@
|
||||
"build": "vite build",
|
||||
"package": "svelte-kit package",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --check --plugin-search-dir=. . && eslint .",
|
||||
"format": "prettier --write --plugin-search-dir=. .",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json --fail-on-warnings",
|
||||
"check:watch": "npm run check -- --watch",
|
||||
"check:code": "npm run format && npm run lint && npm run check",
|
||||
"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:watch": "npm test -- --watch"
|
||||
},
|
||||
@@ -20,6 +24,8 @@
|
||||
"@sveltejs/adapter-auto": "next",
|
||||
"@sveltejs/adapter-node": "next",
|
||||
"@sveltejs/kit": "next",
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/svelte": "^3.2.1",
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"@types/cookie": "^0.4.1",
|
||||
"@types/fluent-ffmpeg": "^2.1.20",
|
||||
@@ -43,7 +49,7 @@
|
||||
"svelte": "^3.44.0",
|
||||
"svelte-check": "^2.7.1",
|
||||
"svelte-jester": "^2.3.2",
|
||||
"svelte-preprocess": "^4.10.6",
|
||||
"svelte-preprocess": "^4.10.7",
|
||||
"tailwindcss": "^3.0.24",
|
||||
"tslib": "^2.3.1",
|
||||
"typescript": "^4.7.4",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { AssetCountByTimeGroupResponseDto } from '@api';
|
||||
let _basePath = '/api';
|
||||
const _basePath = '/api';
|
||||
|
||||
export function getFileUrl(aid: string, did: string, isThumb?: boolean, isWeb?: boolean) {
|
||||
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;
|
||||
|
||||
:root {
|
||||
font-family: 'Work Sans', sans-serif;
|
||||
/* --immich-icon-button-hover-color: #d3d3d3; */
|
||||
font-family: 'Work Sans', sans-serif;
|
||||
/* --immich-icon-button-hover-color: #d3d3d3; */
|
||||
}
|
||||
|
||||
html {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
html::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
/* Track */
|
||||
html::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 16px;
|
||||
background: #f1f1f1;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
/* Handle */
|
||||
html::-webkit-scrollbar-thumb {
|
||||
background: rgba(85, 86, 87, 0.408);
|
||||
border-radius: 16px;
|
||||
background: rgba(85, 86, 87, 0.408);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
/* Handle on hover */
|
||||
html::-webkit-scrollbar-thumb:hover {
|
||||
background: #4250afad;
|
||||
border-radius: 16px;
|
||||
background: #4250afad;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
body {
|
||||
/* min-height: 100vh; */
|
||||
margin: 0;
|
||||
background-color: #f6f8fe;
|
||||
color: #5f6368;
|
||||
/* min-height: 100vh; */
|
||||
margin: 0;
|
||||
background-color: #f6f8fe;
|
||||
color: #5f6368;
|
||||
}
|
||||
|
||||
input:focus-visible {
|
||||
outline-offset: 0px !important;
|
||||
outline: none !important;
|
||||
outline-offset: 0px !important;
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.immich-form-input {
|
||||
@apply bg-slate-100 p-2 rounded-md focus:border-immich-primary text-sm ;
|
||||
}
|
||||
.immich-form-input {
|
||||
@apply bg-slate-100 p-2 rounded-md focus:border-immich-primary text-sm;
|
||||
}
|
||||
|
||||
.immich-form-label {
|
||||
@apply font-medium text-sm text-gray-500;
|
||||
}
|
||||
.immich-form-label {
|
||||
@apply font-medium text-sm text-gray-500;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
||||
.immich-text-button {
|
||||
@apply flex place-items-center place-content-center gap-2 hover:bg-immich-primary/5 p-2 rounded-lg font-medium;
|
||||
}
|
||||
.immich-text-button {
|
||||
@apply flex place-items-center place-content-center gap-2 hover:bg-immich-primary/5 p-2 rounded-lg font-medium;
|
||||
}
|
||||
|
||||
/* width */
|
||||
.immich-scrollbar::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
/* width */
|
||||
.immich-scrollbar::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
/* Track */
|
||||
.immich-scrollbar::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 16px;
|
||||
}
|
||||
/* Track */
|
||||
.immich-scrollbar::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
/* Handle */
|
||||
.immich-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(85, 86, 87, 0.408);
|
||||
border-radius: 16px;
|
||||
}
|
||||
/* Handle */
|
||||
.immich-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(85, 86, 87, 0.408);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
/* Handle on hover */
|
||||
.immich-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #4250afad;
|
||||
border-radius: 16px;
|
||||
}
|
||||
/* Handle on hover */
|
||||
.immich-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #4250afad;
|
||||
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 { createEventDispatcher, onMount } from 'svelte';
|
||||
import DotsVertical from 'svelte-material-icons/DotsVertical.svelte';
|
||||
import { fly } from 'svelte/transition';
|
||||
import CircleIconButton from '../shared-components/circle-icon-button.svelte';
|
||||
|
||||
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 dispatchShowContextMenu = createEventDispatcher<OnShowContextMenu>();
|
||||
|
||||
@@ -29,7 +28,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const { data } = await api.assetApi.getAssetThumbnail(thubmnailId!, ThumbnailFormat.Jpeg, {
|
||||
const { data } = await api.assetApi.getAssetThumbnail(thubmnailId, ThumbnailFormat.Jpeg, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
@@ -53,11 +52,13 @@
|
||||
<div
|
||||
class="h-[339px] w-[275px] hover:cursor-pointer mt-4 relative"
|
||||
on:click={() => dispatchClick('click', album)}
|
||||
data-testid="album-card"
|
||||
>
|
||||
<div
|
||||
id={`icon-${album.id}`}
|
||||
class="absolute top-2 right-2"
|
||||
on:click|stopPropagation|preventDefault={showAlbumContextMenu}
|
||||
data-testid="context-button-parent"
|
||||
>
|
||||
<CircleIconButton
|
||||
logo={DotsVertical}
|
||||
@@ -72,15 +73,16 @@
|
||||
src={imageData}
|
||||
alt={album.id}
|
||||
class={`object-cover h-full w-full transition-all z-0 rounded-xl duration-300 hover:shadow-lg`}
|
||||
data-testid="album-image"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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}
|
||||
</p>
|
||||
|
||||
<span class="text-xs flex gap-2">
|
||||
<span class="text-xs flex gap-2" data-testid="album-details">
|
||||
<p>{album.assetCount} items</p>
|
||||
|
||||
{#if album.shared}
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
import CircleAvatar from '../shared-components/circle-avatar.svelte';
|
||||
import ImmichThumbnail from '../shared-components/immich-thumbnail.svelte';
|
||||
import AssetSelection from './asset-selection.svelte';
|
||||
import _ from 'lodash-es';
|
||||
import UserSelectionModal from './user-selection-modal.svelte';
|
||||
import ShareInfoModal from './share-info-modal.svelte';
|
||||
import CircleIconButton from '../shared-components/circle-icon-button.svelte';
|
||||
@@ -27,12 +26,16 @@
|
||||
NotificationType
|
||||
} from '../shared-components/notification/notification';
|
||||
import { browser } from '$app/env';
|
||||
import { albumAssetSelectionStore } from '$lib/stores/album-asset-selection.store';
|
||||
|
||||
export let album: AlbumResponseDto;
|
||||
const { isAlbumAssetSelectionOpen } = albumAssetSelectionStore;
|
||||
|
||||
let isShowAssetViewer = false;
|
||||
|
||||
let isShowAssetSelection = false;
|
||||
|
||||
$: $isAlbumAssetSelectionOpen = isShowAssetSelection;
|
||||
$: {
|
||||
if (browser) {
|
||||
if (isShowAssetSelection) {
|
||||
@@ -53,8 +56,7 @@
|
||||
let currentViewAssetIndex = 0;
|
||||
|
||||
let viewWidth: number;
|
||||
let thumbnailSize: number = 300;
|
||||
let border = '';
|
||||
let thumbnailSize = 300;
|
||||
let backUrl = '/albums';
|
||||
let currentAlbumName = '';
|
||||
let currentUser: UserResponseDto;
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
import { createEventDispatcher } from '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 TrashCanOutline from 'svelte-material-icons/TrashCanOutline.svelte';
|
||||
import InformationOutline from 'svelte-material-icons/InformationOutline.svelte';
|
||||
import CircleIconButton from '../shared-components/circle-icon-button.svelte';
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
$: {
|
||||
appearsInAlbums = [];
|
||||
|
||||
api.albumApi.getAllAlbums(undefined, asset.id).then(result => {
|
||||
api.albumApi.getAllAlbums(undefined, asset.id).then((result) => {
|
||||
appearsInAlbums = result.data;
|
||||
});
|
||||
}
|
||||
@@ -29,12 +29,14 @@
|
||||
let isShowDetail = false;
|
||||
let appearsInAlbums: AlbumResponseDto[] = [];
|
||||
|
||||
const onKeyboardPress = (keyInfo: KeyboardEvent) => handleKeyboardPress(keyInfo.key);
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener('keydown', (keyInfo) => handleKeyboardPress(keyInfo.key));
|
||||
document.addEventListener('keydown', onKeyboardPress);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
document.removeEventListener('keydown', (e) => {});
|
||||
document.removeEventListener('keydown', onKeyboardPress);
|
||||
});
|
||||
|
||||
const handleKeyboardPress = (key: string) => {
|
||||
|
||||
@@ -9,10 +9,14 @@
|
||||
import { browser } from '$app/env';
|
||||
import { AssetResponseDto, AlbumResponseDto } from '@api';
|
||||
|
||||
type Leaflet = typeof import('leaflet');
|
||||
type LeafletMap = import('leaflet').Map;
|
||||
type LeafletMarker = import('leaflet').Marker;
|
||||
|
||||
// Map Property
|
||||
let map: any;
|
||||
let leaflet: any;
|
||||
let marker: any;
|
||||
let map: LeafletMap;
|
||||
let leaflet: Leaflet;
|
||||
let marker: LeafletMarker;
|
||||
|
||||
export let asset: AssetResponseDto;
|
||||
$: if (asset.exifInfo?.latitude != null && asset.exifInfo?.longitude != null) {
|
||||
@@ -31,7 +35,6 @@
|
||||
|
||||
async function drawMap(lat: number, lon: number) {
|
||||
if (!leaflet) {
|
||||
// @ts-ignore
|
||||
leaflet = await import('leaflet');
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
export let root: HTMLElement | null = null;
|
||||
|
||||
let intersecting = false;
|
||||
let container: any;
|
||||
let container: HTMLDivElement;
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
onMount(() => {
|
||||
|
||||
@@ -33,7 +33,9 @@
|
||||
|
||||
const assetData = URL.createObjectURL(data);
|
||||
return assetData;
|
||||
} catch (e) {}
|
||||
} catch {
|
||||
// Do nothing
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
||||
import { api, AssetResponseDto, getFileUrl } from '@api';
|
||||
|
||||
@@ -9,8 +9,6 @@
|
||||
|
||||
let asset: AssetResponseDto;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let videoPlayerNode: HTMLVideoElement;
|
||||
let isVideoLoading = true;
|
||||
let videoUrl: string;
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
let error: string;
|
||||
let success: string;
|
||||
|
||||
let password: string = '';
|
||||
let confirmPassowrd: string = '';
|
||||
let password = '';
|
||||
let confirmPassowrd = '';
|
||||
|
||||
let canRegister = false;
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
let error: string;
|
||||
let success: string;
|
||||
|
||||
let password: string = '';
|
||||
let confirmPassowrd: string = '';
|
||||
let password = '';
|
||||
let confirmPassowrd = '';
|
||||
|
||||
let changeChagePassword = false;
|
||||
|
||||
|
||||
@@ -1,122 +1,121 @@
|
||||
<script lang="ts">
|
||||
import { api } from '@api';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { api } from '@api';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
let error: string;
|
||||
let success: string;
|
||||
let error: string;
|
||||
let success: string;
|
||||
|
||||
let password = '';
|
||||
let confirmPassowrd = '';
|
||||
let password = '';
|
||||
let confirmPassowrd = '';
|
||||
|
||||
let canCreateUser = false;
|
||||
let canCreateUser = false;
|
||||
|
||||
$: {
|
||||
if (password !== confirmPassowrd && confirmPassowrd.length > 0) {
|
||||
error = 'Password does not match';
|
||||
canCreateUser = false;
|
||||
} else {
|
||||
error = '';
|
||||
canCreateUser = true;
|
||||
}
|
||||
}
|
||||
const dispatch = createEventDispatcher();
|
||||
$: {
|
||||
if (password !== confirmPassowrd && confirmPassowrd.length > 0) {
|
||||
error = 'Password does not match';
|
||||
canCreateUser = false;
|
||||
} else {
|
||||
error = '';
|
||||
canCreateUser = true;
|
||||
}
|
||||
}
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
async function registerUser(event: SubmitEvent) {
|
||||
if (canCreateUser) {
|
||||
error = '';
|
||||
async function registerUser(event: SubmitEvent) {
|
||||
if (canCreateUser) {
|
||||
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 password = form.get('password');
|
||||
const firstName = form.get('firstName');
|
||||
const lastName = form.get('lastName');
|
||||
const email = form.get('email');
|
||||
const password = form.get('password');
|
||||
const firstName = form.get('firstName');
|
||||
const lastName = form.get('lastName');
|
||||
|
||||
const {status} = await api.userApi.createUser({
|
||||
email: String(email),
|
||||
password: String(password),
|
||||
firstName: String(firstName),
|
||||
lastName: String(lastName)
|
||||
});
|
||||
const { status } = await api.userApi.createUser({
|
||||
email: String(email),
|
||||
password: String(password),
|
||||
firstName: String(firstName),
|
||||
lastName: String(lastName)
|
||||
});
|
||||
|
||||
if (status === 201) {
|
||||
success = 'New user created';
|
||||
if (status === 201) {
|
||||
success = 'New user created';
|
||||
|
||||
dispatch('user-created');
|
||||
return;
|
||||
} else {
|
||||
error = 'Error create user account';
|
||||
}
|
||||
}
|
||||
}
|
||||
dispatch('user-created');
|
||||
return;
|
||||
} else {
|
||||
error = 'Error create user account';
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
<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
|
||||
in.
|
||||
</p>
|
||||
</div>
|
||||
<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" />
|
||||
<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">
|
||||
Please provide your user with the password, they will have to change it on their first sign
|
||||
in.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form on:submit|preventDefault={registerUser} autocomplete="off">
|
||||
<div class="m-4 flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="email">Email</label>
|
||||
<input class="immich-form-input" id="email" name="email" type="email" required/>
|
||||
</div>
|
||||
<form on:submit|preventDefault={registerUser} autocomplete="off">
|
||||
<div class="m-4 flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="email">Email</label>
|
||||
<input class="immich-form-input" id="email" name="email" type="email" required />
|
||||
</div>
|
||||
|
||||
<div class="m-4 flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="password">Password</label>
|
||||
<input
|
||||
class="immich-form-input"
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
bind:value={password}
|
||||
/>
|
||||
</div>
|
||||
<div class="m-4 flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="password">Password</label>
|
||||
<input
|
||||
class="immich-form-input"
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
bind:value={password}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="m-4 flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="confirmPassword">Confirm Password</label>
|
||||
<input
|
||||
class="immich-form-input"
|
||||
id="confirmPassword"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
bind:value={confirmPassowrd}
|
||||
/>
|
||||
</div>
|
||||
<div class="m-4 flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="confirmPassword">Confirm Password</label>
|
||||
<input
|
||||
class="immich-form-input"
|
||||
id="confirmPassword"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
bind:value={confirmPassowrd}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="m-4 flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="firstName">First Name</label>
|
||||
<input class="immich-form-input" id="firstName" name="firstName" type="text" required/>
|
||||
</div>
|
||||
<div class="m-4 flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="firstName">First Name</label>
|
||||
<input class="immich-form-input" id="firstName" name="firstName" type="text" required />
|
||||
</div>
|
||||
|
||||
<div class="m-4 flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="lastName">Last Name</label>
|
||||
<input class="immich-form-input" id="lastName" name="lastName" type="text" required/>
|
||||
</div>
|
||||
<div class="m-4 flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="lastName">Last Name</label>
|
||||
<input class="immich-form-input" id="lastName" name="lastName" type="text" required />
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="text-red-400 ml-4 text-sm">{error}</p>
|
||||
{/if}
|
||||
{#if error}
|
||||
<p class="text-red-400 ml-4 text-sm">{error}</p>
|
||||
{/if}
|
||||
|
||||
{#if success}
|
||||
<p class="text-immich-primary ml-4 text-sm">{success}</p>
|
||||
{/if}
|
||||
<div class="flex w-full">
|
||||
<button
|
||||
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"
|
||||
>Create
|
||||
</button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
{#if success}
|
||||
<p class="text-immich-primary ml-4 text-sm">{success}</p>
|
||||
{/if}
|
||||
<div class="flex w-full">
|
||||
<button
|
||||
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"
|
||||
>Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
const editUser = async (event: SubmitEvent) => {
|
||||
try {
|
||||
const formElement = event.target as HTMLFormElement;
|
||||
@@ -25,8 +24,8 @@
|
||||
|
||||
const { status } = await api.userApi.updateUser({
|
||||
id: user.id,
|
||||
firstName: firstName!.toString(),
|
||||
lastName: lastName!.toString()
|
||||
firstName: firstName?.toString(),
|
||||
lastName: lastName?.toString()
|
||||
});
|
||||
|
||||
if (status == 200) {
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
let error: string;
|
||||
let email: string = '';
|
||||
let password: string = '';
|
||||
let email = '';
|
||||
let password = '';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
import lodash from 'lodash-es';
|
||||
import moment from 'moment';
|
||||
import ImmichThumbnail from '../shared-components/immich-thumbnail.svelte';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import {
|
||||
assetInteractionStore,
|
||||
assetsInAlbumStoreState,
|
||||
@@ -22,7 +21,7 @@
|
||||
|
||||
let isMouseOverGroup = false;
|
||||
let actualBucketHeight: number;
|
||||
let hoveredDateGroup: string = '';
|
||||
let hoveredDateGroup = '';
|
||||
$: assetsGroupByDate = lodash
|
||||
.chain(assets)
|
||||
.groupBy((a) => moment(a.createdAt).format('ddd, MMM DD YYYY'))
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import IntersectionObserver from '../asset-viewer/intersection-observer.svelte';
|
||||
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 Portal from '../shared-components/portal/portal.svelte';
|
||||
import AssetViewer from '../asset-viewer/asset-viewer.svelte';
|
||||
@@ -12,16 +12,23 @@
|
||||
isViewingAssetStoreState,
|
||||
viewingAssetStoreState
|
||||
} from '$lib/stores/asset-interaction.store';
|
||||
import Scrollbar, {
|
||||
OnScrollbarClickDetail,
|
||||
OnScrollbarDragDetail
|
||||
} from '../shared-components/scrollbar/scrollbar.svelte';
|
||||
|
||||
export let isAlbumSelectionMode = false;
|
||||
|
||||
let viewportHeight = 0;
|
||||
let viewportWidth = 0;
|
||||
let assetGridElement: HTMLElement;
|
||||
export let isAlbumSelectionMode = false;
|
||||
let bucketInfo: AssetCountByTimeBucketResponseDto;
|
||||
|
||||
onMount(async () => {
|
||||
const { data: assetCountByTimebucket } = await api.assetApi.getAssetCountByTimeBucket({
|
||||
timeGroup: TimeGroupEnum.Month
|
||||
});
|
||||
bucketInfo = assetCountByTimebucket;
|
||||
|
||||
assetStore.setInitialState(viewportHeight, viewportWidth, assetCountByTimebucket);
|
||||
|
||||
@@ -60,14 +67,46 @@
|
||||
const navigateToNextAsset = () => {
|
||||
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>
|
||||
|
||||
{#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
|
||||
id="asset-grid"
|
||||
class="overflow-y-auto pl-4"
|
||||
class="overflow-y-auto pl-4 scrollbar-hidden"
|
||||
bind:clientHeight={viewportHeight}
|
||||
bind:clientWidth={viewportWidth}
|
||||
bind:this={assetGridElement}
|
||||
on:scroll={handleTimelineScroll}
|
||||
>
|
||||
{#if assetGridElement}
|
||||
<section id="virtual-timeline" style:height={$assetGridState.timelineHeight + 'px'}>
|
||||
@@ -117,5 +156,6 @@
|
||||
<style>
|
||||
#asset-grid {
|
||||
contain: layout;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import FullScreenModal from './full-screen-modal.svelte';
|
||||
export let localVersion: string;
|
||||
export let remoteVersion: string;
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
onDestroy(() => {
|
||||
if (browser) {
|
||||
window.onscroll = function () {};
|
||||
window.onscroll = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
export let user: UserResponseDto;
|
||||
|
||||
// Avatar Size In Pixel
|
||||
export let size: number = 48;
|
||||
export let size = 48;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
*/
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
// TODO: why any here?
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export let logo: any;
|
||||
export let backgroundColor: string = 'transparent';
|
||||
export let hoverColor: string = '#e2e7e9';
|
||||
export let logoColor: string = '#5f6368';
|
||||
export let backgroundColor = 'transparent';
|
||||
export let hoverColor = '#e2e7e9';
|
||||
export let logoColor = '#5f6368';
|
||||
export let size = '24';
|
||||
export let title = '';
|
||||
let iconButton: HTMLButtonElement;
|
||||
|
||||
@@ -6,15 +6,13 @@
|
||||
|
||||
/**
|
||||
* x coordiante of the context menu.
|
||||
* @type {number}
|
||||
*/
|
||||
export let x: number = 0;
|
||||
export let x = 0;
|
||||
|
||||
/**
|
||||
* x coordiante of the context menu.
|
||||
* @type {number}
|
||||
*/
|
||||
export let y: number = 0;
|
||||
export let y = 0;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
|
||||
@@ -12,21 +12,23 @@
|
||||
|
||||
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(() => {
|
||||
if (browser) {
|
||||
document.addEventListener('scroll', (e) => {
|
||||
if (window.pageYOffset > 80) {
|
||||
appBarBorder = 'border border-gray-200 bg-gray-50';
|
||||
} else {
|
||||
appBarBorder = 'bg-immich-bg border border-transparent';
|
||||
}
|
||||
});
|
||||
document.addEventListener('scroll', onScroll);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (browser) {
|
||||
document.removeEventListener('scroll', (e) => {});
|
||||
document.removeEventListener('scroll', onScroll);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import PlayCircleOutline from 'svelte-material-icons/PlayCircleOutline.svelte';
|
||||
import PauseCircleOutline from 'svelte-material-icons/PauseCircleOutline.svelte';
|
||||
import LoadingSpinner from './loading-spinner.svelte';
|
||||
import { api, AssetResponseDto, AssetTypeEnum, getFileUrl, ThumbnailFormat } from '@api';
|
||||
import { AssetResponseDto, AssetTypeEnum, getFileUrl, ThumbnailFormat } from '@api';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
@@ -14,14 +14,14 @@
|
||||
export let groupIndex = 0;
|
||||
export let thumbnailSize: number | undefined = undefined;
|
||||
export let format: ThumbnailFormat = ThumbnailFormat.Webp;
|
||||
export let selected: boolean = false;
|
||||
export let disabled: boolean = false;
|
||||
export let selected = false;
|
||||
export let disabled = false;
|
||||
let imageData: string;
|
||||
|
||||
let mouseOver: boolean = false;
|
||||
let mouseOver = false;
|
||||
$: dispatch('mouse-event', { isMouseOver: mouseOver, selectedGroupIndex: groupIndex });
|
||||
|
||||
let mouseOverIcon: boolean = false;
|
||||
let mouseOverIcon = false;
|
||||
let videoPlayerNode: HTMLVideoElement;
|
||||
let isThumbnailVideoPlaying = false;
|
||||
let calculateVideoDurationIntervalHandler: NodeJS.Timer;
|
||||
@@ -136,7 +136,7 @@
|
||||
<div
|
||||
style:width={`${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'
|
||||
}`}
|
||||
on:mouseenter={handleMouseOverThumbnail}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
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 { clickOutside } from '../../utils/click-outside';
|
||||
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(() => {
|
||||
setTimeout(() => {
|
||||
removeNotificationTimeout = setTimeout(() => {
|
||||
notificationController.removeNotificationById(notificationInfo.id);
|
||||
}, notificationInfo.timeout);
|
||||
|
||||
return () => clearTimeout(removeNotificationTimeout);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -63,8 +67,10 @@
|
||||
>
|
||||
<div class="flex gap-2 place-items-center">
|
||||
<svelte:component this={icon} color={primaryColor()} size="20" />
|
||||
<h2 style:color={primaryColor()} class="font-medium">{notificationInfo.type.toString()}</h2>
|
||||
<h2 style:color={primaryColor()} class="font-medium" data-testid="title">
|
||||
{notificationInfo.type.toString()}
|
||||
</h2>
|
||||
</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>
|
||||
|
||||
@@ -1,25 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { ImmichNotification, notificationController } from './notification';
|
||||
import { notificationController } from './notification';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
import NotificationCard from './notification-card.svelte';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
|
||||
let notificationList: ImmichNotification[] = [];
|
||||
|
||||
notificationController.notificationList.subscribe((list) => {
|
||||
notificationList = list;
|
||||
});
|
||||
const { notificationList } = notificationController;
|
||||
</script>
|
||||
|
||||
{#if notificationList.length > 0}
|
||||
{#if $notificationList.length > 0}
|
||||
<section
|
||||
transition:fade={{ duration: 250 }}
|
||||
id="notification-list"
|
||||
class="absolute right-5 top-[80px] z-[99999999]"
|
||||
>
|
||||
{#each notificationList as notificationInfo (notificationInfo.id)}
|
||||
{#each $notificationList as notificationInfo (notificationInfo.id)}
|
||||
<div animate:flip={{ duration: 250, easing: quintOut }}>
|
||||
<NotificationCard {notificationInfo} />
|
||||
</div>
|
||||
|
||||
@@ -3,13 +3,10 @@
|
||||
|
||||
/**
|
||||
* 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;
|
||||
async function update(newTarget: any) {
|
||||
async function update(newTarget: HTMLElement | string) {
|
||||
target = newTarget;
|
||||
if (typeof target === 'string') {
|
||||
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">
|
||||
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';
|
||||
|
||||
export let scrollTop = 0;
|
||||
export let viewportWidth = 0;
|
||||
export let scrollbarHeight = 0;
|
||||
|
||||
let timelineHeight = 0;
|
||||
$: timelineHeight = $assetGridState.timelineHeight;
|
||||
$: viewportWidth = $assetGridState.viewportWidth;
|
||||
$: timelineScrolltop = (scrollbarPosition / scrollbarHeight) * timelineHeight;
|
||||
|
||||
let segmentScrollbarLayout: SegmentScrollbarLayout[] = [];
|
||||
let isHover = false;
|
||||
let isDragging = false;
|
||||
let hoveredDate: Date;
|
||||
let currentMouseYLocation: number = 0;
|
||||
let currentMouseYLocation = 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;
|
||||
}
|
||||
|
||||
$: {
|
||||
// let result: SegmentScrollbarLayout[] = [];
|
||||
// for (const [i, segment] of assetStoreState.entries()) {
|
||||
// let segmentLayout = new SegmentScrollbarLayout();
|
||||
// segmentLayout.count = segmentData.groups[i].count;
|
||||
// segmentLayout.height =
|
||||
// segment.assets.length == 0
|
||||
// ? getSegmentHeight(segmentData.groups[i].count)
|
||||
// : Math.round((segment.segmentHeight / timelineHeight) * scrollbarHeight);
|
||||
// segmentLayout.timeGroup = segment.segmentDate;
|
||||
// result.push(segmentLayout);
|
||||
// }
|
||||
// segmentScrollbarLayout = result;
|
||||
let result: SegmentScrollbarLayout[] = [];
|
||||
for (const bucket of $assetGridState.buckets) {
|
||||
let segmentLayout = new SegmentScrollbarLayout();
|
||||
segmentLayout.count = bucket.assets.length;
|
||||
segmentLayout.height = (bucket.bucketHeight / timelineHeight) * scrollbarHeight;
|
||||
segmentLayout.timeGroup = bucket.bucketDate;
|
||||
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) => {
|
||||
currentMouseYLocation = e.clientY - 71 - 30;
|
||||
currentMouseYLocation = e.clientY - offset - 30;
|
||||
|
||||
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>
|
||||
|
||||
<div
|
||||
id="immich-scubbable-scrollbar"
|
||||
class="fixed right-0 w-[60px] h-full bg-immich-bg z-[9999] hover:cursor-row-resize"
|
||||
id="immich-scrubbable-scrollbar"
|
||||
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:mouseleave={() => (isHover = false)}
|
||||
on:mouseleave={() => {
|
||||
isHover = false;
|
||||
isDragging = false;
|
||||
}}
|
||||
on:mouseup={handleMouseUp}
|
||||
on:mousemove={handleMouseDrag}
|
||||
on:mousedown={handleMouseDown}
|
||||
style:height={scrollbarHeight + 'px'}
|
||||
>
|
||||
{#if isHover}
|
||||
<div
|
||||
@@ -83,29 +118,33 @@
|
||||
{/if}
|
||||
|
||||
<!-- Scroll Position Indicator Line -->
|
||||
<div
|
||||
class="absolute right-0 w-10 h-[2px] bg-immich-primary"
|
||||
style:top={scrollbarPosition + 'px'}
|
||||
/>
|
||||
|
||||
{#if !isDragging}
|
||||
<div
|
||||
class="absolute right-0 w-10 h-[2px] bg-immich-primary"
|
||||
style:top={scrollbarPosition + 'px'}
|
||||
/>
|
||||
{/if}
|
||||
<!-- Time Segment -->
|
||||
{#each segmentScrollbarLayout as segment, index (segment.timeGroup)}
|
||||
{@const groupDate = new Date(segment.timeGroup)}
|
||||
|
||||
<div
|
||||
class="relative "
|
||||
id="time-segment"
|
||||
class="relative"
|
||||
style:height={segment.height + 'px'}
|
||||
aria-label={segment.timeGroup + ' ' + segment.count}
|
||||
on:mousemove={(e) => handleMouseMove(e, groupDate)}
|
||||
>
|
||||
{#if new Date(segmentScrollbarLayout[index - 1]?.timeGroup).getFullYear() !== groupDate.getFullYear()}
|
||||
<div
|
||||
aria-label={segment.timeGroup + ' ' + segment.count}
|
||||
class="absolute right-0 pr-3 z-10 text-xs font-medium"
|
||||
>
|
||||
{groupDate.getFullYear()}
|
||||
</div>
|
||||
{:else if segment.count > 5}
|
||||
{#if segment.height > 8}
|
||||
<div
|
||||
aria-label={segment.timeGroup + ' ' + segment.count}
|
||||
class="absolute right-0 pr-5 z-10 text-xs font-medium"
|
||||
>
|
||||
{groupDate.getFullYear()}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if segment.height > 5}
|
||||
<div
|
||||
aria-label={segment.timeGroup + ' ' + segment.count}
|
||||
class="absolute right-0 rounded-full h-[4px] w-[4px] mr-3 bg-gray-300 block"
|
||||
@@ -116,7 +155,8 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#immich-scubbable-scrollbar {
|
||||
#immich-scrubbable-scrollbar,
|
||||
#time-segment {
|
||||
contain: layout;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
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 actionType: AdminSideBarSelection | AppSideBarSelection;
|
||||
export let isSelected: boolean;
|
||||
|
||||
@@ -8,16 +8,17 @@
|
||||
import InformationOutline from 'svelte-material-icons/InformationOutline.svelte';
|
||||
import SideBarButton from './side-bar-button.svelte';
|
||||
import StatusBox from '../status-box.svelte';
|
||||
import { AlbumCountResponseDto, api, AssetCountByUserIdResponseDto } from '@api';
|
||||
import { api } from '@api';
|
||||
import { fade } from 'svelte/transition';
|
||||
import LoadingSpinner from '../loading-spinner.svelte';
|
||||
|
||||
let selectedAction: AppSideBarSelection;
|
||||
|
||||
let showAssetCount: boolean = false;
|
||||
let showAssetCount = false;
|
||||
let showSharingCount = false;
|
||||
let showAlbumsCount = false;
|
||||
|
||||
// let domCount = 0;
|
||||
onMount(async () => {
|
||||
if ($page.routeId == 'albums') {
|
||||
selectedAction = AppSideBarSelection.ALBUMS;
|
||||
@@ -26,6 +27,10 @@
|
||||
} else if ($page.routeId == 'sharing') {
|
||||
selectedAction = AppSideBarSelection.SHARING;
|
||||
}
|
||||
|
||||
// setInterval(() => {
|
||||
// domCount = document.getElementsByTagName('*').length;
|
||||
// }, 500);
|
||||
});
|
||||
|
||||
const getAssetCount = async () => {
|
||||
@@ -48,6 +53,7 @@
|
||||
</script>
|
||||
|
||||
<section id="sidebar" class="flex flex-col gap-1 pt-8 pr-6">
|
||||
<!-- {domCount} -->
|
||||
<a
|
||||
sveltekit:prefetch
|
||||
sveltekit:noscroll
|
||||
@@ -66,12 +72,12 @@
|
||||
on:mouseenter={() => (showAssetCount = true)}
|
||||
on:mouseleave={() => (showAssetCount = false)}
|
||||
>
|
||||
<InformationOutline size={18} color="#4250af" />
|
||||
<InformationOutline size={18} color="#989a9f" />
|
||||
{#if showAssetCount}
|
||||
<div
|
||||
transition:fade={{ duration: 200 }}
|
||||
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()}
|
||||
<LoadingSpinner />
|
||||
@@ -99,18 +105,18 @@
|
||||
on:mouseenter={() => (showSharingCount = true)}
|
||||
on:mouseleave={() => (showSharingCount = false)}
|
||||
>
|
||||
<InformationOutline size={18} color="#4250af" />
|
||||
<InformationOutline size={18} color="#989a9f" />
|
||||
{#if showSharingCount}
|
||||
<div
|
||||
transition:fade={{ duration: 200 }}
|
||||
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()}
|
||||
<LoadingSpinner />
|
||||
{:then data}
|
||||
<div>
|
||||
<p>{data.shared + data.sharing} albums</p>
|
||||
<p>{data.shared + data.sharing} Albums</p>
|
||||
</div>
|
||||
{/await}
|
||||
</div>
|
||||
@@ -134,18 +140,18 @@
|
||||
on:mouseenter={() => (showAlbumsCount = true)}
|
||||
on:mouseleave={() => (showAlbumsCount = false)}
|
||||
>
|
||||
<InformationOutline size={18} color="#4250af" />
|
||||
<InformationOutline size={18} color="#989a9f" />
|
||||
{#if showAlbumsCount}
|
||||
<div
|
||||
transition:fade={{ duration: 200 }}
|
||||
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()}
|
||||
<LoadingSpinner />
|
||||
{:then data}
|
||||
<div>
|
||||
<p>{data.owned} albums</p>
|
||||
<p>{data.owned} Albums</p>
|
||||
</div>
|
||||
{/await}
|
||||
</div>
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
import CloudUploadOutline from 'svelte-material-icons/CloudUploadOutline.svelte';
|
||||
import WindowMinimize from 'svelte-material-icons/WindowMinimize.svelte';
|
||||
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';
|
||||
let showDetail = true;
|
||||
|
||||
@@ -22,9 +20,13 @@
|
||||
const blob = new Blob([arrayBufferView], { type: 'image/jpeg' });
|
||||
const urlCreator = window.URL || window.webkitURL;
|
||||
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}`);
|
||||
img.src = imageUrl;
|
||||
} catch (e) {}
|
||||
} catch {
|
||||
// Do nothing?
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { AlbumResponseDto, api, ThumbnailFormat, UserResponseDto } from '@api';
|
||||
import { onMount } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
export let album: AlbumResponseDto;
|
||||
@@ -11,7 +10,7 @@
|
||||
return '/no-thumbnail.png';
|
||||
}
|
||||
|
||||
const { data } = await api.assetApi.getAssetThumbnail(thubmnailId!, ThumbnailFormat.Webp, {
|
||||
const { data } = await api.assetApi.getAssetThumbnail(thubmnailId, ThumbnailFormat.Webp, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
if (data instanceof Blob) {
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
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
|
||||
* 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
|
||||
*/
|
||||
viewportHeight: number = 0;
|
||||
viewportHeight = 0;
|
||||
|
||||
/**
|
||||
* The fixed viewport width in pixel
|
||||
*/
|
||||
viewportWidth: number = 0;
|
||||
viewportWidth = 0;
|
||||
|
||||
/**
|
||||
* 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, derived, readable } from 'svelte/store';
|
||||
import { writable } from 'svelte/store';
|
||||
import lodash from 'lodash-es';
|
||||
import _ from 'lodash';
|
||||
import moment from 'moment';
|
||||
import { api, AssetCountByTimeBucketResponseDto, AssetResponseDto } from '@api';
|
||||
import { api, AssetCountByTimeBucketResponseDto } from '@api';
|
||||
import { AssetGridState } from '$lib/models/asset-grid-state';
|
||||
import { calculateViewportHeightByNumberOfAsset } from '$lib/utils/viewport-utils';
|
||||
|
||||
@@ -24,7 +21,7 @@ function createAssetStore() {
|
||||
_loadingBucketState = state;
|
||||
});
|
||||
/**
|
||||
* Set intial state
|
||||
* Set initial state
|
||||
* @param viewportHeight
|
||||
* @param viewportWidth
|
||||
* @param data
|
||||
@@ -37,7 +34,7 @@ function createAssetStore() {
|
||||
assetGridState.set({
|
||||
viewportHeight,
|
||||
viewportWidth,
|
||||
timelineHeight: calculateViewportHeightByNumberOfAsset(data.totalCount, viewportWidth),
|
||||
timelineHeight: 0,
|
||||
buckets: data.buckets.map((d) => ({
|
||||
bucketDate: d.timeBucket,
|
||||
bucketHeight: calculateViewportHeightByNumberOfAsset(d.count, viewportWidth),
|
||||
@@ -46,6 +43,12 @@ function createAssetStore() {
|
||||
})),
|
||||
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) => {
|
||||
@@ -78,6 +81,7 @@ function createAssetStore() {
|
||||
|
||||
return state;
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (e: any) {
|
||||
if (e.name === 'CanceledError') {
|
||||
return;
|
||||
@@ -110,10 +114,19 @@ function createAssetStore() {
|
||||
});
|
||||
};
|
||||
|
||||
const updateBucketHeight = (bucket: string, height: number) => {
|
||||
const updateBucketHeight = (bucket: string, actualBucketHeight: number) => {
|
||||
assetGridState.update((state) => {
|
||||
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;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -19,7 +19,8 @@ export const openWebsocketConnection = () => {
|
||||
};
|
||||
|
||||
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) => {
|
||||
console.log('Websocket Error', e);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export function clickOutside(node: Node) {
|
||||
const handleClick = (event: any) => {
|
||||
if (!node.contains(event.target)) {
|
||||
const handleClick = (event: Event) => {
|
||||
const targetNode = event.target as Node | null;
|
||||
if (!node.contains(targetNode)) {
|
||||
node.dispatchEvent(new CustomEvent('out-click'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -27,14 +27,18 @@ export enum UploadType {
|
||||
|
||||
export const openFileUploadDialog = (uploadType: UploadType) => {
|
||||
try {
|
||||
let fileSelector = document.createElement('input');
|
||||
const fileSelector = document.createElement('input');
|
||||
|
||||
fileSelector.type = 'file';
|
||||
fileSelector.multiple = true;
|
||||
fileSelector.accept = 'image/*,video/*,.heic,.heif,.dng,.3gp';
|
||||
|
||||
fileSelector.onchange = async (e: any) => {
|
||||
const files = Array.from<File>(e.target.files);
|
||||
fileSelector.onchange = async (e: Event) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (!target.files) {
|
||||
return;
|
||||
}
|
||||
const files = Array.from<File>(target.files);
|
||||
|
||||
if (files.length > 50) {
|
||||
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) {
|
||||
const assetType = asset.type.split('/')[0].toUpperCase();
|
||||
const temp = asset.name.split('.');
|
||||
@@ -123,9 +128,10 @@ async function fileUploader(asset: File, uploadType: UploadType) {
|
||||
|
||||
if (status === 200) {
|
||||
if (data.isExist) {
|
||||
if (uploadType === UploadType.ALBUM && data.id) {
|
||||
const dataId = data.id;
|
||||
if (uploadType === UploadType.ALBUM && dataId) {
|
||||
albumUploadAssetStore.asset.update((a) => {
|
||||
return [...a, data.id!];
|
||||
return [...a, dataId];
|
||||
});
|
||||
}
|
||||
return;
|
||||
@@ -145,7 +151,7 @@ async function fileUploader(asset: File, uploadType: UploadType) {
|
||||
uploadAssetsStore.addNewUploadAsset(newUploadAsset);
|
||||
};
|
||||
|
||||
request.upload.onload = (event) => {
|
||||
request.upload.onload = () => {
|
||||
setTimeout(() => {
|
||||
uploadAssetsStore.removeUploadAsset(deviceAssetId);
|
||||
}, 1000);
|
||||
@@ -170,7 +176,7 @@ async function fileUploader(asset: File, uploadType: UploadType) {
|
||||
};
|
||||
|
||||
// listen for `error` event
|
||||
request.upload.onerror = (event) => {
|
||||
request.upload.onerror = () => {
|
||||
uploadAssetsStore.removeUploadAsset(deviceAssetId);
|
||||
};
|
||||
|
||||
@@ -192,9 +198,10 @@ async function fileUploader(asset: File, uploadType: UploadType) {
|
||||
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) {
|
||||
let extraMsg = respBody ? ' ' + respBody.message : '';
|
||||
const extraMsg = respBody ? ' ' + respBody.message : '';
|
||||
|
||||
notificationController.show({
|
||||
type: NotificationType.Error,
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
*/
|
||||
|
||||
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 height = rows * thumbnailHeight;
|
||||
return height;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { serverApi, TimeGroupEnum } from '@api';
|
||||
import { serverApi } from '@api';
|
||||
import * as cookieParser from 'cookie';
|
||||
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
import UploadPanel from '$lib/components/shared-components/upload-panel.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { checkAppVersion } from '$lib/utils/check-app-version';
|
||||
import { page } from '$app/stores';
|
||||
import { afterNavigate, beforeNavigate } from '$app/navigation';
|
||||
import NavigationLoadingBar from '$lib/components/shared-components/navigation-loading-bar.svelte';
|
||||
import NotificationList from '$lib/components/shared-components/notification/notification-list.svelte';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { serverApi, UserResponseDto } from '@api';
|
||||
import { serverApi } from '@api';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ parent }) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { AlbumResponseDto, serverApi } from '@api';
|
||||
import { serverApi } from '@api';
|
||||
|
||||
export const load: PageServerLoad = async ({ parent }) => {
|
||||
try {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import NavigationBar from '$lib/components/shared-components/navigation-bar.svelte';
|
||||
import SideBar from '$lib/components/shared-components/side-bar/side-bar.svelte';
|
||||
import PlusBoxOutline from 'svelte-material-icons/PlusBoxOutline.svelte';
|
||||
import { useAlbums } from './albums-bloc';
|
||||
import { useAlbums } from './albums.bloc';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 {
|
||||
notificationController,
|
||||
@@ -6,8 +6,6 @@
|
||||
import type { PageData } from './$types';
|
||||
|
||||
import { openFileUploadDialog, UploadType } from '$lib/utils/file-uploader';
|
||||
import { onMount } from 'svelte';
|
||||
import { closeWebsocketConnection, openWebsocketConnection } from '$lib/stores/websocket';
|
||||
import {
|
||||
assetInteractionStore,
|
||||
isMultiSelectStoreState,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import preprocess from 'svelte-preprocess';
|
||||
import adapter from '@sveltejs/adapter-node';
|
||||
import path from 'path';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: preprocess(),
|
||||
|
||||
Reference in New Issue
Block a user