mirror of
https://github.com/immich-app/immich.git
synced 2025-09-29 15:31:13 -04:00
Merge branch 'main' into fix/save-album-sort
This commit is contained in:
commit
28a8a8c89c
@ -533,6 +533,7 @@
|
||||
"background_backup_running_error": "Background backup is currently running, cannot start manual backup",
|
||||
"background_location_permission": "Background location permission",
|
||||
"background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name",
|
||||
"background_options": "Background Options",
|
||||
"backup": "Backup",
|
||||
"backup_album_selection_page_albums_device": "Albums on device ({count})",
|
||||
"backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude",
|
||||
@ -540,6 +541,7 @@
|
||||
"backup_album_selection_page_select_albums": "Select albums",
|
||||
"backup_album_selection_page_selection_info": "Selection Info",
|
||||
"backup_album_selection_page_total_assets": "Total unique assets",
|
||||
"backup_albums_sync": "Backup albums synchronization",
|
||||
"backup_all": "All",
|
||||
"backup_background_service_backup_failed_message": "Failed to backup assets. Retrying…",
|
||||
"backup_background_service_connection_failed_message": "Failed to connect to the server. Retrying…",
|
||||
@ -656,6 +658,8 @@
|
||||
"change_pin_code": "Change PIN code",
|
||||
"change_your_password": "Change your password",
|
||||
"changed_visibility_successfully": "Changed visibility successfully",
|
||||
"charging": "Charging",
|
||||
"charging_requirement_mobile_backup": "Background backup requires the device to be charging",
|
||||
"check_corrupt_asset_backup": "Check for corrupt asset backups",
|
||||
"check_corrupt_asset_backup_button": "Perform check",
|
||||
"check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.",
|
||||
@ -1351,6 +1355,7 @@
|
||||
"name_or_nickname": "Name or nickname",
|
||||
"network_requirement_photos_upload": "Use cellular data to backup photos",
|
||||
"network_requirement_videos_upload": "Use cellular data to backup videos",
|
||||
"network_requirements": "Network Requirements",
|
||||
"network_requirements_updated": "Network requirements changed, resetting backup queue",
|
||||
"networking_settings": "Networking",
|
||||
"networking_subtitle": "Manage the server endpoint settings",
|
||||
|
@ -1,6 +1,6 @@
|
||||
[tools]
|
||||
node = "22.19.0"
|
||||
flutter = "3.35.3"
|
||||
flutter = "3.35.4"
|
||||
pnpm = "10.14.0"
|
||||
dart = "3.8.2"
|
||||
|
||||
|
@ -1,3 +1,3 @@
|
||||
{
|
||||
"flutter": "3.35.3"
|
||||
"flutter": "3.35.4"
|
||||
}
|
2
mobile/.vscode/settings.json
vendored
2
mobile/.vscode/settings.json
vendored
@ -1,5 +1,5 @@
|
||||
{
|
||||
"dart.flutterSdkPath": ".fvm/versions/3.35.3",
|
||||
"dart.flutterSdkPath": ".fvm/versions/3.35.4",
|
||||
"dart.lineLength": 120,
|
||||
"[dart]": {
|
||||
"editor.rulers": [120]
|
||||
|
@ -3,6 +3,7 @@ package app.alextran.immich
|
||||
import android.app.Application
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.WorkManager
|
||||
import app.alextran.immich.background.BackgroundWorkerApiImpl
|
||||
|
||||
class ImmichApp : Application() {
|
||||
override fun onCreate() {
|
||||
@ -14,6 +15,8 @@ class ImmichApp : Application() {
|
||||
// Thus, the BackupWorker is not started. If the system kills the process after each initialization
|
||||
// (because of low memory etc.), the backup is never performed.
|
||||
// As a workaround, we also run a backup check when initializing the application
|
||||
|
||||
ContentObserverWorker.startBackupWorker(context = this, delayMilliseconds = 0)
|
||||
BackgroundWorkerApiImpl.enqueueBackgroundWorker(this)
|
||||
}
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ package app.alextran.immich
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.ext.SdkExtensions
|
||||
import app.alextran.immich.background.BackgroundEngineLock
|
||||
import app.alextran.immich.background.BackgroundWorkerApiImpl
|
||||
import app.alextran.immich.background.BackgroundWorkerFgHostApi
|
||||
import app.alextran.immich.connectivity.ConnectivityApi
|
||||
@ -25,6 +26,7 @@ class MainActivity : FlutterFragmentActivity() {
|
||||
fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) {
|
||||
flutterEngine.plugins.add(BackgroundServicePlugin())
|
||||
flutterEngine.plugins.add(HttpSSLOptionsPlugin())
|
||||
flutterEngine.plugins.add(BackgroundEngineLock())
|
||||
|
||||
val messenger = flutterEngine.dartExecutor.binaryMessenger
|
||||
val nativeSyncApiImpl =
|
||||
|
@ -0,0 +1,33 @@
|
||||
package app.alextran.immich.background
|
||||
|
||||
import android.util.Log
|
||||
import androidx.work.WorkManager
|
||||
import io.flutter.embedding.engine.FlutterEngineCache
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
private const val TAG = "BackgroundEngineLock"
|
||||
|
||||
class BackgroundEngineLock : FlutterPlugin {
|
||||
companion object {
|
||||
const val ENGINE_CACHE_KEY = "immich::background_worker::engine"
|
||||
var engineCount = AtomicInteger(0)
|
||||
}
|
||||
|
||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
// work manager task is running while the main app is opened, cancel the worker
|
||||
if (engineCount.incrementAndGet() > 1 && FlutterEngineCache.getInstance()
|
||||
.get(ENGINE_CACHE_KEY) != null
|
||||
) {
|
||||
WorkManager.getInstance(binding.applicationContext)
|
||||
.cancelUniqueWork(BackgroundWorkerApiImpl.BACKGROUND_WORKER_NAME)
|
||||
FlutterEngineCache.getInstance().remove(ENGINE_CACHE_KEY)
|
||||
}
|
||||
Log.i(TAG, "Flutter engine attached. Attached Engines count: $engineCount")
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
engineCount.decrementAndGet()
|
||||
Log.i(TAG, "Flutter engine detached. Attached Engines count: $engineCount")
|
||||
}
|
||||
}
|
@ -37,6 +37,36 @@ private object BackgroundWorkerPigeonUtils {
|
||||
)
|
||||
}
|
||||
}
|
||||
fun deepEquals(a: Any?, b: Any?): Boolean {
|
||||
if (a is ByteArray && b is ByteArray) {
|
||||
return a.contentEquals(b)
|
||||
}
|
||||
if (a is IntArray && b is IntArray) {
|
||||
return a.contentEquals(b)
|
||||
}
|
||||
if (a is LongArray && b is LongArray) {
|
||||
return a.contentEquals(b)
|
||||
}
|
||||
if (a is DoubleArray && b is DoubleArray) {
|
||||
return a.contentEquals(b)
|
||||
}
|
||||
if (a is Array<*> && b is Array<*>) {
|
||||
return a.size == b.size &&
|
||||
a.indices.all{ deepEquals(a[it], b[it]) }
|
||||
}
|
||||
if (a is List<*> && b is List<*>) {
|
||||
return a.size == b.size &&
|
||||
a.indices.all{ deepEquals(a[it], b[it]) }
|
||||
}
|
||||
if (a is Map<*, *> && b is Map<*, *>) {
|
||||
return a.size == b.size && a.all {
|
||||
(b as Map<Any?, Any?>).containsKey(it.key) &&
|
||||
deepEquals(it.value, b[it.key])
|
||||
}
|
||||
}
|
||||
return a == b
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
@ -50,18 +80,63 @@ class FlutterError (
|
||||
override val message: String? = null,
|
||||
val details: Any? = null
|
||||
) : Throwable()
|
||||
|
||||
/** Generated class from Pigeon that represents data sent in messages. */
|
||||
data class BackgroundWorkerSettings (
|
||||
val requiresCharging: Boolean,
|
||||
val minimumDelaySeconds: Long
|
||||
)
|
||||
{
|
||||
companion object {
|
||||
fun fromList(pigeonVar_list: List<Any?>): BackgroundWorkerSettings {
|
||||
val requiresCharging = pigeonVar_list[0] as Boolean
|
||||
val minimumDelaySeconds = pigeonVar_list[1] as Long
|
||||
return BackgroundWorkerSettings(requiresCharging, minimumDelaySeconds)
|
||||
}
|
||||
}
|
||||
fun toList(): List<Any?> {
|
||||
return listOf(
|
||||
requiresCharging,
|
||||
minimumDelaySeconds,
|
||||
)
|
||||
}
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other !is BackgroundWorkerSettings) {
|
||||
return false
|
||||
}
|
||||
if (this === other) {
|
||||
return true
|
||||
}
|
||||
return BackgroundWorkerPigeonUtils.deepEquals(toList(), other.toList()) }
|
||||
|
||||
override fun hashCode(): Int = toList().hashCode()
|
||||
}
|
||||
private open class BackgroundWorkerPigeonCodec : StandardMessageCodec() {
|
||||
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||
return super.readValueOfType(type, buffer)
|
||||
return when (type) {
|
||||
129.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
BackgroundWorkerSettings.fromList(it)
|
||||
}
|
||||
}
|
||||
else -> super.readValueOfType(type, buffer)
|
||||
}
|
||||
}
|
||||
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
|
||||
super.writeValue(stream, value)
|
||||
when (value) {
|
||||
is BackgroundWorkerSettings -> {
|
||||
stream.write(129)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
else -> super.writeValue(stream, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||
interface BackgroundWorkerFgHostApi {
|
||||
fun enable()
|
||||
fun configure(settings: BackgroundWorkerSettings)
|
||||
fun disable()
|
||||
|
||||
companion object {
|
||||
@ -89,6 +164,24 @@ interface BackgroundWorkerFgHostApi {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.configure$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val settingsArg = args[0] as BackgroundWorkerSettings
|
||||
val wrapped: List<Any?> = try {
|
||||
api.configure(settingsArg)
|
||||
listOf(null)
|
||||
} catch (exception: Throwable) {
|
||||
BackgroundWorkerPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
|
@ -19,6 +19,7 @@ import com.google.common.util.concurrent.ListenableFuture
|
||||
import com.google.common.util.concurrent.SettableFuture
|
||||
import io.flutter.FlutterInjector
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.embedding.engine.FlutterEngineCache
|
||||
import io.flutter.embedding.engine.dart.DartExecutor
|
||||
import io.flutter.embedding.engine.loader.FlutterLoader
|
||||
import java.util.concurrent.TimeUnit
|
||||
@ -75,6 +76,9 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
|
||||
|
||||
loader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) {
|
||||
engine = FlutterEngine(ctx)
|
||||
FlutterEngineCache.getInstance().remove(BackgroundEngineLock.ENGINE_CACHE_KEY);
|
||||
FlutterEngineCache.getInstance()
|
||||
.put(BackgroundEngineLock.ENGINE_CACHE_KEY, engine!!)
|
||||
|
||||
// Register custom plugins
|
||||
MainActivity.registerPlugins(ctx, engine!!)
|
||||
@ -188,6 +192,7 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
|
||||
isComplete = true
|
||||
engine?.destroy()
|
||||
engine = null
|
||||
FlutterEngineCache.getInstance().remove(BackgroundEngineLock.ENGINE_CACHE_KEY);
|
||||
flutterApi = null
|
||||
notificationManager.cancel(NOTIFICATION_ID)
|
||||
waitForForegroundPromotion()
|
||||
|
@ -1,6 +1,7 @@
|
||||
package app.alextran.immich.background
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import androidx.work.BackoffPolicy
|
||||
@ -10,7 +11,7 @@ import androidx.work.OneTimeWorkRequest
|
||||
import androidx.work.WorkManager
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
private const val TAG = "BackgroundUploadImpl"
|
||||
private const val TAG = "BackgroundWorkerApiImpl"
|
||||
|
||||
class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
|
||||
private val ctx: Context = context.applicationContext
|
||||
@ -19,25 +20,34 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
|
||||
enqueueMediaObserver(ctx)
|
||||
}
|
||||
|
||||
override fun configure(settings: BackgroundWorkerSettings) {
|
||||
BackgroundWorkerPreferences(ctx).updateSettings(settings)
|
||||
enqueueMediaObserver(ctx)
|
||||
}
|
||||
|
||||
override fun disable() {
|
||||
WorkManager.getInstance(ctx).cancelUniqueWork(OBSERVER_WORKER_NAME)
|
||||
WorkManager.getInstance(ctx).cancelUniqueWork(BACKGROUND_WORKER_NAME)
|
||||
WorkManager.getInstance(ctx).apply {
|
||||
cancelUniqueWork(OBSERVER_WORKER_NAME)
|
||||
cancelUniqueWork(BACKGROUND_WORKER_NAME)
|
||||
}
|
||||
Log.i(TAG, "Cancelled background upload tasks")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val BACKGROUND_WORKER_NAME = "immich/BackgroundWorkerV1"
|
||||
const val BACKGROUND_WORKER_NAME = "immich/BackgroundWorkerV1"
|
||||
private const val OBSERVER_WORKER_NAME = "immich/MediaObserverV1"
|
||||
|
||||
fun enqueueMediaObserver(ctx: Context) {
|
||||
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(30, TimeUnit.SECONDS)
|
||||
.setTriggerContentMaxDelay(3, TimeUnit.MINUTES)
|
||||
.build()
|
||||
val settings = BackgroundWorkerPreferences(ctx).getSettings()
|
||||
val constraints = Constraints.Builder().apply {
|
||||
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(settings.minimumDelaySeconds, TimeUnit.SECONDS)
|
||||
setTriggerContentMaxDelay(settings.minimumDelaySeconds * 10, TimeUnit.MINUTES)
|
||||
setRequiresCharging(settings.requiresCharging)
|
||||
}.build()
|
||||
|
||||
val work = OneTimeWorkRequest.Builder(MediaObserver::class.java)
|
||||
.setConstraints(constraints)
|
||||
@ -45,7 +55,10 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
|
||||
WorkManager.getInstance(ctx)
|
||||
.enqueueUniqueWork(OBSERVER_WORKER_NAME, ExistingWorkPolicy.REPLACE, work)
|
||||
|
||||
Log.i(TAG, "Enqueued media observer worker with name: $OBSERVER_WORKER_NAME")
|
||||
Log.i(
|
||||
TAG,
|
||||
"Enqueued media observer worker with name: $OBSERVER_WORKER_NAME and settings: $settings"
|
||||
)
|
||||
}
|
||||
|
||||
fun enqueueBackgroundWorker(ctx: Context) {
|
||||
@ -56,9 +69,39 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
|
||||
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES)
|
||||
.build()
|
||||
WorkManager.getInstance(ctx)
|
||||
.enqueueUniqueWork(BACKGROUND_WORKER_NAME, ExistingWorkPolicy.REPLACE, work)
|
||||
.enqueueUniqueWork(BACKGROUND_WORKER_NAME, ExistingWorkPolicy.KEEP, work)
|
||||
|
||||
Log.i(TAG, "Enqueued background worker with name: $BACKGROUND_WORKER_NAME")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class BackgroundWorkerPreferences(private val ctx: Context) {
|
||||
companion object {
|
||||
private const val SHARED_PREF_NAME = "Immich::BackgroundWorker"
|
||||
private const val SHARED_PREF_MIN_DELAY_KEY = "BackgroundWorker::minDelaySeconds"
|
||||
private const val SHARED_PREF_REQUIRE_CHARGING_KEY = "BackgroundWorker::requireCharging"
|
||||
|
||||
private const val DEFAULT_MIN_DELAY_SECONDS = 30L
|
||||
private const val DEFAULT_REQUIRE_CHARGING = false
|
||||
}
|
||||
|
||||
private val sp: SharedPreferences by lazy {
|
||||
ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
fun updateSettings(settings: BackgroundWorkerSettings) {
|
||||
sp.edit().apply {
|
||||
putLong(SHARED_PREF_MIN_DELAY_KEY, settings.minimumDelaySeconds)
|
||||
putBoolean(SHARED_PREF_REQUIRE_CHARGING_KEY, settings.requiresCharging)
|
||||
apply()
|
||||
}
|
||||
}
|
||||
|
||||
fun getSettings(): BackgroundWorkerSettings {
|
||||
return BackgroundWorkerSettings(
|
||||
minimumDelaySeconds = sp.getLong(SHARED_PREF_MIN_DELAY_KEY, DEFAULT_MIN_DELAY_SECONDS),
|
||||
requiresCharging = sp.getBoolean(SHARED_PREF_REQUIRE_CHARGING_KEY, DEFAULT_REQUIRE_CHARGING),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -8,7 +8,6 @@ import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.CancellationSignal
|
||||
import android.os.OperationCanceledException
|
||||
import android.provider.MediaStore
|
||||
import android.provider.MediaStore.Images
|
||||
import android.provider.MediaStore.Video
|
||||
import android.util.Size
|
||||
@ -19,7 +18,6 @@ import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.Priority
|
||||
import com.bumptech.glide.load.DecodeFormat
|
||||
import java.util.Base64
|
||||
import java.util.HashMap
|
||||
import java.util.concurrent.CancellationException
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.Future
|
||||
@ -202,8 +200,10 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
|
||||
val source = ImageDecoder.createSource(resolver, uri)
|
||||
signal.throwIfCanceled()
|
||||
ImageDecoder.decodeBitmap(source) { decoder, info, _ ->
|
||||
val sampleSize = max(1, min(info.size.width / targetWidth, info.size.height / targetHeight))
|
||||
decoder.setTargetSampleSize(sampleSize)
|
||||
if (targetWidth > 0 && targetHeight > 0) {
|
||||
val sample = max(1, min(info.size.width / targetWidth, info.size.height / targetHeight))
|
||||
decoder.setTargetSampleSize(sample)
|
||||
}
|
||||
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
|
||||
decoder.setTargetColorSpace(ColorSpace.get(ColorSpace.Named.SRGB))
|
||||
}
|
||||
|
@ -50,11 +50,119 @@ private func nilOrValue<T>(_ value: Any?) -> T? {
|
||||
return value as! T?
|
||||
}
|
||||
|
||||
func deepEqualsBackgroundWorker(_ lhs: Any?, _ rhs: Any?) -> Bool {
|
||||
let cleanLhs = nilOrValue(lhs) as Any?
|
||||
let cleanRhs = nilOrValue(rhs) as Any?
|
||||
switch (cleanLhs, cleanRhs) {
|
||||
case (nil, nil):
|
||||
return true
|
||||
|
||||
case (nil, _), (_, nil):
|
||||
return false
|
||||
|
||||
case is (Void, Void):
|
||||
return true
|
||||
|
||||
case let (cleanLhsHashable, cleanRhsHashable) as (AnyHashable, AnyHashable):
|
||||
return cleanLhsHashable == cleanRhsHashable
|
||||
|
||||
case let (cleanLhsArray, cleanRhsArray) as ([Any?], [Any?]):
|
||||
guard cleanLhsArray.count == cleanRhsArray.count else { return false }
|
||||
for (index, element) in cleanLhsArray.enumerated() {
|
||||
if !deepEqualsBackgroundWorker(element, cleanRhsArray[index]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
|
||||
case let (cleanLhsDictionary, cleanRhsDictionary) as ([AnyHashable: Any?], [AnyHashable: Any?]):
|
||||
guard cleanLhsDictionary.count == cleanRhsDictionary.count else { return false }
|
||||
for (key, cleanLhsValue) in cleanLhsDictionary {
|
||||
guard cleanRhsDictionary.index(forKey: key) != nil else { return false }
|
||||
if !deepEqualsBackgroundWorker(cleanLhsValue, cleanRhsDictionary[key]!) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
|
||||
default:
|
||||
// Any other type shouldn't be able to be used with pigeon. File an issue if you find this to be untrue.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func deepHashBackgroundWorker(value: Any?, hasher: inout Hasher) {
|
||||
if let valueList = value as? [AnyHashable] {
|
||||
for item in valueList { deepHashBackgroundWorker(value: item, hasher: &hasher) }
|
||||
return
|
||||
}
|
||||
|
||||
if let valueDict = value as? [AnyHashable: AnyHashable] {
|
||||
for key in valueDict.keys {
|
||||
hasher.combine(key)
|
||||
deepHashBackgroundWorker(value: valueDict[key]!, hasher: &hasher)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if let hashableValue = value as? AnyHashable {
|
||||
hasher.combine(hashableValue.hashValue)
|
||||
}
|
||||
|
||||
return hasher.combine(String(describing: value))
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Generated class from Pigeon that represents data sent in messages.
|
||||
struct BackgroundWorkerSettings: Hashable {
|
||||
var requiresCharging: Bool
|
||||
var minimumDelaySeconds: Int64
|
||||
|
||||
|
||||
// swift-format-ignore: AlwaysUseLowerCamelCase
|
||||
static func fromList(_ pigeonVar_list: [Any?]) -> BackgroundWorkerSettings? {
|
||||
let requiresCharging = pigeonVar_list[0] as! Bool
|
||||
let minimumDelaySeconds = pigeonVar_list[1] as! Int64
|
||||
|
||||
return BackgroundWorkerSettings(
|
||||
requiresCharging: requiresCharging,
|
||||
minimumDelaySeconds: minimumDelaySeconds
|
||||
)
|
||||
}
|
||||
func toList() -> [Any?] {
|
||||
return [
|
||||
requiresCharging,
|
||||
minimumDelaySeconds,
|
||||
]
|
||||
}
|
||||
static func == (lhs: BackgroundWorkerSettings, rhs: BackgroundWorkerSettings) -> Bool {
|
||||
return deepEqualsBackgroundWorker(lhs.toList(), rhs.toList()) }
|
||||
func hash(into hasher: inout Hasher) {
|
||||
deepHashBackgroundWorker(value: toList(), hasher: &hasher)
|
||||
}
|
||||
}
|
||||
|
||||
private class BackgroundWorkerPigeonCodecReader: FlutterStandardReader {
|
||||
override func readValue(ofType type: UInt8) -> Any? {
|
||||
switch type {
|
||||
case 129:
|
||||
return BackgroundWorkerSettings.fromList(self.readValue() as! [Any?])
|
||||
default:
|
||||
return super.readValue(ofType: type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class BackgroundWorkerPigeonCodecWriter: FlutterStandardWriter {
|
||||
override func writeValue(_ value: Any) {
|
||||
if let value = value as? BackgroundWorkerSettings {
|
||||
super.writeByte(129)
|
||||
super.writeValue(value.toList())
|
||||
} else {
|
||||
super.writeValue(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class BackgroundWorkerPigeonCodecReaderWriter: FlutterStandardReaderWriter {
|
||||
@ -74,6 +182,7 @@ class BackgroundWorkerPigeonCodec: FlutterStandardMessageCodec, @unchecked Senda
|
||||
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
||||
protocol BackgroundWorkerFgHostApi {
|
||||
func enable() throws
|
||||
func configure(settings: BackgroundWorkerSettings) throws
|
||||
func disable() throws
|
||||
}
|
||||
|
||||
@ -96,6 +205,21 @@ class BackgroundWorkerFgHostApiSetup {
|
||||
} else {
|
||||
enableChannel.setMessageHandler(nil)
|
||||
}
|
||||
let configureChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.configure\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
configureChannel.setMessageHandler { message, reply in
|
||||
let args = message as! [Any?]
|
||||
let settingsArg = args[0] as! BackgroundWorkerSettings
|
||||
do {
|
||||
try api.configure(settings: settingsArg)
|
||||
reply(wrapResult(nil))
|
||||
} catch {
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
configureChannel.setMessageHandler(nil)
|
||||
}
|
||||
let disableChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
disableChannel.setMessageHandler { _, reply in
|
||||
|
@ -8,6 +8,10 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
|
||||
print("BackgroundUploadImpl:enbale Background worker scheduled")
|
||||
}
|
||||
|
||||
func configure(settings: BackgroundWorkerSettings) throws {
|
||||
// Android only
|
||||
}
|
||||
|
||||
func disable() throws {
|
||||
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.refreshTaskID);
|
||||
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.processingTaskID);
|
||||
|
@ -105,7 +105,7 @@ class ThumbnailApiImpl: ThumbnailApi {
|
||||
var image: UIImage?
|
||||
Self.imageManager.requestImage(
|
||||
for: asset,
|
||||
targetSize: CGSize(width: Double(width), height: Double(height)),
|
||||
targetSize: width > 0 && height > 0 ? CGSize(width: Double(width), height: Double(height)) : PHImageManagerMaximumSize,
|
||||
contentMode: .aspectFill,
|
||||
options: Self.requestOptions,
|
||||
resultHandler: { (_image, info) -> Void in
|
||||
|
@ -43,6 +43,17 @@ class BackgroundWorkerFgService {
|
||||
// TODO: Move this call to native side once old timeline is removed
|
||||
Future<void> enable() => _foregroundHostApi.enable();
|
||||
|
||||
Future<void> configure({int? minimumDelaySeconds, bool? requireCharging}) => _foregroundHostApi.configure(
|
||||
BackgroundWorkerSettings(
|
||||
minimumDelaySeconds:
|
||||
minimumDelaySeconds ??
|
||||
Store.get(AppSettingsEnum.backupTriggerDelay.storeKey, AppSettingsEnum.backupTriggerDelay.defaultValue),
|
||||
requiresCharging:
|
||||
requireCharging ??
|
||||
Store.get(AppSettingsEnum.backupRequireCharging.storeKey, AppSettingsEnum.backupRequireCharging.defaultValue),
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> disable() => _foregroundHostApi.disable();
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,3 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@ -11,13 +9,11 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/backup/backup_toggle_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/widgets/backup/backup_info_card.dart';
|
||||
|
||||
@RoutePage()
|
||||
@ -29,8 +25,6 @@ class DriftBackupPage extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
|
||||
Timer? _countPoller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@ -39,42 +33,9 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup)) {
|
||||
_startCountPolling();
|
||||
}
|
||||
|
||||
ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
|
||||
}
|
||||
|
||||
void _startCountPolling() {
|
||||
_countPoller?.cancel();
|
||||
_countPoller = Timer.periodic(const Duration(seconds: 5), (timer) async {
|
||||
if (!mounted) {
|
||||
timer.cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
final currentUser = ref.read(currentUserProvider);
|
||||
if (currentUser == null) {
|
||||
timer.cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
|
||||
});
|
||||
}
|
||||
|
||||
void _stopCountPolling() {
|
||||
_countPoller?.cancel();
|
||||
_countPoller = null;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stopCountPolling();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selectedAlbum = ref
|
||||
@ -94,12 +55,10 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
|
||||
await backgroundManager.syncRemote();
|
||||
await backupNotifier.getBackupStatus(currentUser.id);
|
||||
await backupNotifier.startBackup(currentUser.id);
|
||||
_startCountPolling();
|
||||
}
|
||||
|
||||
Future<void> stopBackup() async {
|
||||
await backupNotifier.cancel();
|
||||
_stopCountPolling();
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
|
79
mobile/lib/platform/background_worker_api.g.dart
generated
79
mobile/lib/platform/background_worker_api.g.dart
generated
@ -25,6 +25,57 @@ List<Object?> wrapResponse({Object? result, PlatformException? error, bool empty
|
||||
return <Object?>[error.code, error.message, error.details];
|
||||
}
|
||||
|
||||
bool _deepEquals(Object? a, Object? b) {
|
||||
if (a is List && b is List) {
|
||||
return a.length == b.length && a.indexed.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]));
|
||||
}
|
||||
if (a is Map && b is Map) {
|
||||
return a.length == b.length &&
|
||||
a.entries.every(
|
||||
(MapEntry<Object?, Object?> entry) =>
|
||||
(b as Map<Object?, Object?>).containsKey(entry.key) && _deepEquals(entry.value, b[entry.key]),
|
||||
);
|
||||
}
|
||||
return a == b;
|
||||
}
|
||||
|
||||
class BackgroundWorkerSettings {
|
||||
BackgroundWorkerSettings({required this.requiresCharging, required this.minimumDelaySeconds});
|
||||
|
||||
bool requiresCharging;
|
||||
|
||||
int minimumDelaySeconds;
|
||||
|
||||
List<Object?> _toList() {
|
||||
return <Object?>[requiresCharging, minimumDelaySeconds];
|
||||
}
|
||||
|
||||
Object encode() {
|
||||
return _toList();
|
||||
}
|
||||
|
||||
static BackgroundWorkerSettings decode(Object result) {
|
||||
result as List<Object?>;
|
||||
return BackgroundWorkerSettings(requiresCharging: result[0]! as bool, minimumDelaySeconds: result[1]! as int);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
bool operator ==(Object other) {
|
||||
if (other is! BackgroundWorkerSettings || other.runtimeType != runtimeType) {
|
||||
return false;
|
||||
}
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
return _deepEquals(encode(), other.encode());
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
int get hashCode => Object.hashAll(_toList());
|
||||
}
|
||||
|
||||
class _PigeonCodec extends StandardMessageCodec {
|
||||
const _PigeonCodec();
|
||||
@override
|
||||
@ -32,6 +83,9 @@ class _PigeonCodec extends StandardMessageCodec {
|
||||
if (value is int) {
|
||||
buffer.putUint8(4);
|
||||
buffer.putInt64(value);
|
||||
} else if (value is BackgroundWorkerSettings) {
|
||||
buffer.putUint8(129);
|
||||
writeValue(buffer, value.encode());
|
||||
} else {
|
||||
super.writeValue(buffer, value);
|
||||
}
|
||||
@ -40,6 +94,8 @@ class _PigeonCodec extends StandardMessageCodec {
|
||||
@override
|
||||
Object? readValueOfType(int type, ReadBuffer buffer) {
|
||||
switch (type) {
|
||||
case 129:
|
||||
return BackgroundWorkerSettings.decode(readValue(buffer)!);
|
||||
default:
|
||||
return super.readValueOfType(type, buffer);
|
||||
}
|
||||
@ -82,6 +138,29 @@ class BackgroundWorkerFgHostApi {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> configure(BackgroundWorkerSettings settings) async {
|
||||
final String pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.configure$pigeonVar_messageChannelSuffix';
|
||||
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[settings]);
|
||||
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
if (pigeonVar_replyList == null) {
|
||||
throw _createConnectionError(pigeonVar_channelName);
|
||||
} else if (pigeonVar_replyList.length > 1) {
|
||||
throw PlatformException(
|
||||
code: pigeonVar_replyList[0]! as String,
|
||||
message: pigeonVar_replyList[1] as String?,
|
||||
details: pigeonVar_replyList[2],
|
||||
);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> disable() async {
|
||||
final String pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable$pigeonVar_messageChannelSuffix';
|
||||
|
@ -87,7 +87,7 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
|
||||
|
||||
return BaseBottomSheet(
|
||||
controller: sheetController,
|
||||
initialChildSize: 0.45,
|
||||
initialChildSize: widget.minChildSize ?? 0.15,
|
||||
minChildSize: widget.minChildSize,
|
||||
maxChildSize: 0.85,
|
||||
shouldCloseOnMinExtent: false,
|
||||
|
@ -84,7 +84,8 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
|
||||
|
||||
return BaseBottomSheet(
|
||||
controller: sheetController,
|
||||
initialChildSize: 0.45,
|
||||
initialChildSize: 0.22,
|
||||
minChildSize: 0.22,
|
||||
maxChildSize: 0.85,
|
||||
shouldCloseOnMinExtent: false,
|
||||
actions: [
|
||||
|
@ -4,6 +4,8 @@ import 'dart:ui';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/loaders/image_request.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
|
||||
@ -88,13 +90,26 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
|
||||
}
|
||||
|
||||
final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio;
|
||||
final request = this.request = LocalImageRequest(
|
||||
var request = this.request = LocalImageRequest(
|
||||
localId: key.id,
|
||||
size: Size(size.width * devicePixelRatio, size.height * devicePixelRatio),
|
||||
assetType: key.assetType,
|
||||
);
|
||||
|
||||
yield* loadRequest(request, decode);
|
||||
|
||||
if (!Store.get(StoreKey.loadOriginal, false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCancelled) {
|
||||
evict();
|
||||
return;
|
||||
}
|
||||
|
||||
request = this.request = LocalImageRequest(localId: key.id, assetType: key.assetType, size: Size.zero);
|
||||
|
||||
yield* loadRequest(request, decode);
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -356,7 +356,14 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
children: [
|
||||
timeline,
|
||||
if (!isSelectionMode && isMultiSelectEnabled) ...[
|
||||
const Positioned(top: 60, left: 25, child: _MultiSelectStatusButton()),
|
||||
Positioned(
|
||||
top: MediaQuery.paddingOf(context).top,
|
||||
left: 25,
|
||||
child: const SizedBox(
|
||||
height: kToolbarHeight,
|
||||
child: Center(child: _MultiSelectStatusButton()),
|
||||
),
|
||||
),
|
||||
if (widget.bottomSheet != null) widget.bottomSheet!,
|
||||
],
|
||||
],
|
||||
|
@ -12,8 +12,8 @@ import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/services/upload.service.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
|
||||
class EnqueueStatus {
|
||||
final int enqueueCount;
|
||||
@ -234,6 +234,10 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
|
||||
|
||||
switch (update.status) {
|
||||
case TaskStatus.complete:
|
||||
if (update.task.group == kBackupGroup) {
|
||||
state = state.copyWith(backupCount: state.backupCount + 1, remainderCount: state.remainderCount - 1);
|
||||
}
|
||||
|
||||
// Remove the completed task from the upload items
|
||||
if (state.uploadItems.containsKey(taskId)) {
|
||||
Future.delayed(const Duration(milliseconds: 1000), () {
|
||||
|
@ -52,6 +52,9 @@ enum AppSettingsEnum<T> {
|
||||
useCellularForUploadPhotos<bool>(StoreKey.useWifiForUploadPhotos, null, false),
|
||||
readonlyModeEnabled<bool>(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false),
|
||||
albumGridView<bool>(StoreKey.albumGridView, "albumGridView", false);
|
||||
backupRequireCharging<bool>(StoreKey.backupRequireCharging, null, false),
|
||||
backupTriggerDelay<int>(StoreKey.backupTriggerDelay, null, 30),
|
||||
readonlyModeEnabled<bool>(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false);
|
||||
|
||||
const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue);
|
||||
|
||||
|
@ -102,7 +102,7 @@ enum ActionButtonType {
|
||||
context.asset.hasRemote,
|
||||
ActionButtonType.deleteLocal =>
|
||||
!context.isInLockedView && //
|
||||
context.asset.storage == AssetState.local,
|
||||
context.asset.hasLocal,
|
||||
ActionButtonType.upload =>
|
||||
!context.isInLockedView && //
|
||||
context.asset.storage == AssetState.local,
|
||||
|
@ -65,7 +65,7 @@ Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
|
||||
// Handle migration only for this version
|
||||
// TODO: remove when old timeline is removed
|
||||
final needBetaMigration = Store.tryGet(StoreKey.needBetaMigration);
|
||||
if (version == 15 && needBetaMigration == null) {
|
||||
if (version >= 15 && needBetaMigration == null) {
|
||||
// Check both databases directly instead of relying on cache
|
||||
|
||||
final isBeta = Store.tryGet(StoreKey.betaTimeline);
|
||||
@ -73,7 +73,7 @@ Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
|
||||
|
||||
// For new installations, no migration needed
|
||||
// For existing installations, only migrate if beta timeline is not enabled (null or false)
|
||||
if (isNewInstallation || isBeta == true) {
|
||||
if (isNewInstallation || isBeta == true || (version > 15 && isBeta == null)) {
|
||||
await Store.put(StoreKey.needBetaMigration, false);
|
||||
await Store.put(StoreKey.betaTimeline, true);
|
||||
} else {
|
||||
|
@ -75,86 +75,79 @@ class _MesmerizingSliverAppBarState extends ConsumerState<RemoteAlbumSliverAppBa
|
||||
const Shadow(offset: Offset(0, 2), blurRadius: 0, color: Colors.transparent),
|
||||
];
|
||||
|
||||
if (isMultiSelectEnabled) {
|
||||
return SliverToBoxAdapter(
|
||||
child: switch (_scrollProgress) {
|
||||
< 0.8 => const SizedBox(height: 120),
|
||||
_ => const SizedBox(height: 452),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return SliverAppBar(
|
||||
expandedHeight: 400.0,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
snap: false,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: Icon(
|
||||
Platform.isIOS ? Icons.arrow_back_ios_new_rounded : Icons.arrow_back,
|
||||
color: actionIconColor,
|
||||
shadows: actionIconShadows,
|
||||
),
|
||||
onPressed: () => context.navigateTo(const TabShellRoute(children: [DriftAlbumsRoute()])),
|
||||
),
|
||||
actions: [
|
||||
if (widget.onToggleAlbumOrder != null)
|
||||
IconButton(
|
||||
icon: Icon(Icons.swap_vert_rounded, color: actionIconColor, shadows: actionIconShadows),
|
||||
onPressed: widget.onToggleAlbumOrder,
|
||||
),
|
||||
if (currentAlbum.isActivityEnabled && currentAlbum.isShared)
|
||||
IconButton(
|
||||
icon: Icon(Icons.chat_outlined, color: actionIconColor, shadows: actionIconShadows),
|
||||
onPressed: widget.onActivity,
|
||||
),
|
||||
if (widget.onShowOptions != null)
|
||||
IconButton(
|
||||
icon: Icon(Icons.more_vert, color: actionIconColor, shadows: actionIconShadows),
|
||||
onPressed: widget.onShowOptions,
|
||||
),
|
||||
],
|
||||
title: Builder(
|
||||
builder: (context) {
|
||||
final settings = context.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>();
|
||||
final scrollProgress = _calculateScrollProgress(settings);
|
||||
|
||||
return AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: scrollProgress > 0.95
|
||||
? Text(
|
||||
currentAlbum.name,
|
||||
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.w600, fontSize: 18),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
),
|
||||
flexibleSpace: Builder(
|
||||
builder: (context) {
|
||||
final settings = context.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>();
|
||||
final scrollProgress = _calculateScrollProgress(settings);
|
||||
|
||||
// Update scroll progress for the leading button
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _scrollProgress != scrollProgress) {
|
||||
setState(() {
|
||||
_scrollProgress = scrollProgress;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return FlexibleSpaceBar(
|
||||
background: _ExpandedBackground(
|
||||
scrollProgress: scrollProgress,
|
||||
icon: widget.icon,
|
||||
onEditTitle: widget.onEditTitle,
|
||||
return SliverAppBar(
|
||||
expandedHeight: 400.0,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
snap: false,
|
||||
elevation: 0,
|
||||
leading: isMultiSelectEnabled
|
||||
? const SizedBox.shrink()
|
||||
: IconButton(
|
||||
icon: Icon(
|
||||
Platform.isIOS ? Icons.arrow_back_ios_new_rounded : Icons.arrow_back,
|
||||
color: actionIconColor,
|
||||
shadows: actionIconShadows,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
onPressed: () => context.navigateTo(const TabShellRoute(children: [DriftAlbumsRoute()])),
|
||||
),
|
||||
actions: [
|
||||
if (widget.onToggleAlbumOrder != null)
|
||||
IconButton(
|
||||
icon: Icon(Icons.swap_vert_rounded, color: actionIconColor, shadows: actionIconShadows),
|
||||
onPressed: widget.onToggleAlbumOrder,
|
||||
),
|
||||
if (currentAlbum.isActivityEnabled && currentAlbum.isShared)
|
||||
IconButton(
|
||||
icon: Icon(Icons.chat_outlined, color: actionIconColor, shadows: actionIconShadows),
|
||||
onPressed: widget.onActivity,
|
||||
),
|
||||
if (widget.onShowOptions != null)
|
||||
IconButton(
|
||||
icon: Icon(Icons.more_vert, color: actionIconColor, shadows: actionIconShadows),
|
||||
onPressed: widget.onShowOptions,
|
||||
),
|
||||
],
|
||||
title: Builder(
|
||||
builder: (context) {
|
||||
final settings = context.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>();
|
||||
final scrollProgress = _calculateScrollProgress(settings);
|
||||
|
||||
return AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: scrollProgress > 0.95
|
||||
? Text(
|
||||
currentAlbum.name,
|
||||
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.w600, fontSize: 18),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
),
|
||||
flexibleSpace: Builder(
|
||||
builder: (context) {
|
||||
final settings = context.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>();
|
||||
final scrollProgress = _calculateScrollProgress(settings);
|
||||
|
||||
// Update scroll progress for the leading button
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _scrollProgress != scrollProgress) {
|
||||
setState(() {
|
||||
_scrollProgress = scrollProgress;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return FlexibleSpaceBar(
|
||||
background: _ExpandedBackground(
|
||||
scrollProgress: scrollProgress,
|
||||
icon: widget.icon,
|
||||
onEditTitle: widget.onEditTitle,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,14 +1,19 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/sync_linked_album.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/domain/services/sync_linked_album.service.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart';
|
||||
@ -18,12 +23,40 @@ class DriftBackupSettings extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return const SettingsSubPageScaffold(
|
||||
return SettingsSubPageScaffold(
|
||||
settings: [
|
||||
_UseWifiForUploadVideosButton(),
|
||||
_UseWifiForUploadPhotosButton(),
|
||||
Divider(indent: 16, endIndent: 16),
|
||||
_AlbumSyncActionButton(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0),
|
||||
child: Text(
|
||||
"network_requirements".t(context: context).toUpperCase(),
|
||||
style: context.textTheme.labelSmall?.copyWith(color: context.colorScheme.onSurface.withValues(alpha: 0.7)),
|
||||
),
|
||||
),
|
||||
const _UseWifiForUploadVideosButton(),
|
||||
const _UseWifiForUploadPhotosButton(),
|
||||
if (CurrentPlatform.isAndroid) ...[
|
||||
const Divider(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0),
|
||||
child: Text(
|
||||
"background_options".t(context: context).toUpperCase(),
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: context.colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
),
|
||||
const _BackupOnlyWhenChargingButton(),
|
||||
const _BackupDelaySlider(),
|
||||
],
|
||||
const Divider(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0),
|
||||
child: Text(
|
||||
"backup_albums_sync".t(context: context).toUpperCase(),
|
||||
style: context.textTheme.labelSmall?.copyWith(color: context.colorScheme.onSurface.withValues(alpha: 0.7)),
|
||||
),
|
||||
),
|
||||
const _AlbumSyncActionButton(),
|
||||
],
|
||||
);
|
||||
}
|
||||
@ -151,30 +184,59 @@ class _AlbumSyncActionButtonState extends ConsumerState<_AlbumSyncActionButton>
|
||||
}
|
||||
}
|
||||
|
||||
class _UseWifiForUploadVideosButton extends ConsumerWidget {
|
||||
const _UseWifiForUploadVideosButton();
|
||||
class _SettingsSwitchTile extends ConsumerStatefulWidget {
|
||||
final AppSettingsEnum<bool> appSettingsEnum;
|
||||
final String titleKey;
|
||||
final String subtitleKey;
|
||||
final void Function(bool?)? onChanged;
|
||||
|
||||
const _SettingsSwitchTile({
|
||||
required this.appSettingsEnum,
|
||||
required this.titleKey,
|
||||
required this.subtitleKey,
|
||||
this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final valueStream = Store.watch(StoreKey.useWifiForUploadVideos);
|
||||
ConsumerState createState() => _SettingsSwitchTileState();
|
||||
}
|
||||
|
||||
class _SettingsSwitchTileState extends ConsumerState<_SettingsSwitchTile> {
|
||||
late final Stream<bool?> valueStream;
|
||||
late final StreamSubscription<bool?> subscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
valueStream = Store.watch(widget.appSettingsEnum.storeKey).asBroadcastStream();
|
||||
subscription = valueStream.listen((value) {
|
||||
widget.onChanged?.call(value);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
subscription.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
title: Text(
|
||||
"videos".t(context: context),
|
||||
widget.titleKey.t(context: context),
|
||||
style: context.textTheme.titleMedium?.copyWith(color: context.primaryColor),
|
||||
),
|
||||
subtitle: Text("network_requirement_videos_upload".t(context: context), style: context.textTheme.labelLarge),
|
||||
subtitle: Text(widget.subtitleKey.t(context: context), style: context.textTheme.labelLarge),
|
||||
trailing: StreamBuilder(
|
||||
stream: valueStream,
|
||||
initialData: Store.tryGet(StoreKey.useWifiForUploadVideos) ?? false,
|
||||
initialData: Store.tryGet(widget.appSettingsEnum.storeKey) ?? widget.appSettingsEnum.defaultValue,
|
||||
builder: (context, snapshot) {
|
||||
final value = snapshot.data ?? false;
|
||||
return Switch(
|
||||
value: value,
|
||||
onChanged: (bool newValue) async {
|
||||
await ref
|
||||
.read(appSettingsServiceProvider)
|
||||
.setSetting(AppSettingsEnum.useCellularForUploadVideos, newValue);
|
||||
await ref.read(appSettingsServiceProvider).setSetting(widget.appSettingsEnum, newValue);
|
||||
},
|
||||
);
|
||||
},
|
||||
@ -183,34 +245,135 @@ class _UseWifiForUploadVideosButton extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _UseWifiForUploadVideosButton extends ConsumerWidget {
|
||||
const _UseWifiForUploadVideosButton();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return const _SettingsSwitchTile(
|
||||
appSettingsEnum: AppSettingsEnum.useCellularForUploadVideos,
|
||||
titleKey: "videos",
|
||||
subtitleKey: "network_requirement_videos_upload",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _UseWifiForUploadPhotosButton extends ConsumerWidget {
|
||||
const _UseWifiForUploadPhotosButton();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final valueStream = Store.watch(StoreKey.useWifiForUploadPhotos);
|
||||
|
||||
return ListTile(
|
||||
title: Text(
|
||||
"photos".t(context: context),
|
||||
style: context.textTheme.titleMedium?.copyWith(color: context.primaryColor),
|
||||
),
|
||||
subtitle: Text("network_requirement_photos_upload".t(context: context), style: context.textTheme.labelLarge),
|
||||
trailing: StreamBuilder(
|
||||
stream: valueStream,
|
||||
initialData: Store.tryGet(StoreKey.useWifiForUploadPhotos) ?? false,
|
||||
builder: (context, snapshot) {
|
||||
final value = snapshot.data ?? false;
|
||||
return Switch(
|
||||
value: value,
|
||||
onChanged: (bool newValue) async {
|
||||
await ref
|
||||
.read(appSettingsServiceProvider)
|
||||
.setSetting(AppSettingsEnum.useCellularForUploadPhotos, newValue);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
return const _SettingsSwitchTile(
|
||||
appSettingsEnum: AppSettingsEnum.useCellularForUploadPhotos,
|
||||
titleKey: "photos",
|
||||
subtitleKey: "network_requirement_photos_upload",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BackupOnlyWhenChargingButton extends ConsumerWidget {
|
||||
const _BackupOnlyWhenChargingButton();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return _SettingsSwitchTile(
|
||||
appSettingsEnum: AppSettingsEnum.backupRequireCharging,
|
||||
titleKey: "charging",
|
||||
subtitleKey: "charging_requirement_mobile_backup",
|
||||
onChanged: (value) {
|
||||
ref.read(backgroundWorkerFgServiceProvider).configure(requireCharging: value ?? false);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BackupDelaySlider extends ConsumerStatefulWidget {
|
||||
const _BackupDelaySlider();
|
||||
|
||||
@override
|
||||
ConsumerState<_BackupDelaySlider> createState() => _BackupDelaySliderState();
|
||||
}
|
||||
|
||||
class _BackupDelaySliderState extends ConsumerState<_BackupDelaySlider> {
|
||||
late final Stream<int?> valueStream;
|
||||
late final StreamSubscription<int?> subscription;
|
||||
late int currentValue;
|
||||
|
||||
static int backupDelayToSliderValue(int ms) => switch (ms) {
|
||||
5 => 0,
|
||||
30 => 1,
|
||||
120 => 2,
|
||||
_ => 3,
|
||||
};
|
||||
|
||||
static int backupDelayToSeconds(int v) => switch (v) {
|
||||
0 => 5,
|
||||
1 => 30,
|
||||
2 => 120,
|
||||
_ => 600,
|
||||
};
|
||||
|
||||
static String formatBackupDelaySliderValue(int v) => switch (v) {
|
||||
0 => 'setting_notifications_notify_seconds'.tr(namedArgs: {'count': '5'}),
|
||||
1 => 'setting_notifications_notify_seconds'.tr(namedArgs: {'count': '30'}),
|
||||
2 => 'setting_notifications_notify_minutes'.tr(namedArgs: {'count': '2'}),
|
||||
_ => 'setting_notifications_notify_minutes'.tr(namedArgs: {'count': '10'}),
|
||||
};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final initialValue =
|
||||
Store.tryGet(AppSettingsEnum.backupTriggerDelay.storeKey) ?? AppSettingsEnum.backupTriggerDelay.defaultValue;
|
||||
currentValue = backupDelayToSliderValue(initialValue);
|
||||
|
||||
valueStream = Store.watch(AppSettingsEnum.backupTriggerDelay.storeKey).asBroadcastStream();
|
||||
subscription = valueStream.listen((value) {
|
||||
if (mounted && value != null) {
|
||||
setState(() {
|
||||
currentValue = backupDelayToSliderValue(value);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
subscription.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0, top: 8.0),
|
||||
child: Text(
|
||||
'backup_controller_page_background_delay'.tr(
|
||||
namedArgs: {'duration': formatBackupDelaySliderValue(currentValue)},
|
||||
),
|
||||
style: context.textTheme.titleMedium?.copyWith(color: context.primaryColor),
|
||||
),
|
||||
),
|
||||
Slider(
|
||||
value: currentValue.toDouble(),
|
||||
onChanged: (double v) {
|
||||
setState(() {
|
||||
currentValue = v.toInt();
|
||||
});
|
||||
},
|
||||
onChangeEnd: (double v) async {
|
||||
final milliseconds = backupDelayToSeconds(v.toInt());
|
||||
await ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.backupTriggerDelay, milliseconds);
|
||||
},
|
||||
max: 3.0,
|
||||
min: 0.0,
|
||||
divisions: 3,
|
||||
label: formatBackupDelaySliderValue(currentValue),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -11,10 +11,19 @@ import 'package:pigeon/pigeon.dart';
|
||||
dartPackageName: 'immich_mobile',
|
||||
),
|
||||
)
|
||||
class BackgroundWorkerSettings {
|
||||
final bool requiresCharging;
|
||||
final int minimumDelaySeconds;
|
||||
|
||||
const BackgroundWorkerSettings({required this.requiresCharging, required this.minimumDelaySeconds});
|
||||
}
|
||||
|
||||
@HostApi()
|
||||
abstract class BackgroundWorkerFgHostApi {
|
||||
void enable();
|
||||
|
||||
void configure(BackgroundWorkerSettings settings);
|
||||
|
||||
void disable();
|
||||
}
|
||||
|
||||
|
@ -2171,4 +2171,4 @@ packages:
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.8.0 <4.0.0"
|
||||
flutter: ">=3.35.3"
|
||||
flutter: ">=3.35.4"
|
||||
|
@ -6,7 +6,7 @@ version: 1.142.1+3015
|
||||
|
||||
environment:
|
||||
sdk: '>=3.8.0 <4.0.0'
|
||||
flutter: 3.35.3
|
||||
flutter: 3.35.4
|
||||
|
||||
isar_version: &isar_version 3.1.8
|
||||
|
||||
|
@ -502,6 +502,21 @@ void main() {
|
||||
|
||||
expect(ActionButtonType.deleteLocal.shouldShow(context), isFalse);
|
||||
});
|
||||
|
||||
test('should show when asset is merged', () {
|
||||
final context = ActionButtonContext(
|
||||
asset: mergedAsset,
|
||||
isOwner: true,
|
||||
isArchived: false,
|
||||
isTrashEnabled: true,
|
||||
isInLockedView: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.deleteLocal.shouldShow(context), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('upload button', () {
|
||||
|
@ -56,7 +56,7 @@ RUN if [ "$(dpkg --print-architecture)" = "arm64" ]; then \
|
||||
# Flutter SDK
|
||||
# https://flutter.dev/docs/development/tools/sdk/releases?tab=linux
|
||||
ENV FLUTTER_CHANNEL="stable"
|
||||
ENV FLUTTER_VERSION="3.35.3"
|
||||
ENV FLUTTER_VERSION="3.35.4"
|
||||
ENV FLUTTER_HOME=/flutter
|
||||
ENV PATH=${PATH}:${FLUTTER_HOME}/bin
|
||||
|
||||
|
24
web/src/lib/components/ServerAboutItem.svelte
Normal file
24
web/src/lib/components/ServerAboutItem.svelte
Normal file
@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { Label, Link, Text } from '@immich/ui';
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
title: string;
|
||||
version?: string;
|
||||
versionHref?: string;
|
||||
class?: string;
|
||||
};
|
||||
|
||||
const { id, title, version, versionHref, class: className }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class={className}>
|
||||
<Label size="small" color="primary" for={id}>{title}</Label>
|
||||
<Text size="small" color="muted" {id}>
|
||||
{#if versionHref}
|
||||
<Link external href={versionHref}>{version}</Link>
|
||||
{:else}
|
||||
{version}
|
||||
{/if}
|
||||
</Text>
|
||||
</div>
|
@ -163,7 +163,7 @@
|
||||
{#if config.storageTemplate.enabled}
|
||||
<hr />
|
||||
|
||||
<h3 class="text-base font-medium text-immich-primary dark:text-immich-dark-primary">{$t('variables')}</h3>
|
||||
<h3 class="text-base font-medium text-primary">{$t('variables')}</h3>
|
||||
|
||||
<section class="support-date">
|
||||
{#await getSupportDateTimeFormat()}
|
||||
@ -180,7 +180,7 @@
|
||||
</section>
|
||||
|
||||
<div class="flex flex-col mt-4">
|
||||
<h3 class="text-base font-medium text-immich-primary dark:text-immich-dark-primary">{$t('template')}</h3>
|
||||
<h3 class="text-base font-medium text-primary">{$t('template')}</h3>
|
||||
|
||||
<div class="my-2 text-sm">
|
||||
<h4 class="uppercase">{$t('preview')}</h4>
|
||||
@ -192,7 +192,7 @@
|
||||
values={{ length: parsedTemplate().length + $user.id.length + 'UPLOAD_LOCATION'.length, limit: 260 }}
|
||||
>
|
||||
{#snippet children({ message })}
|
||||
<span class="font-semibold text-immich-primary dark:text-immich-dark-primary">{message}</span>
|
||||
<span class="font-semibold text-primary">{message}</span>
|
||||
{/snippet}
|
||||
</FormatMessage>
|
||||
</p>
|
||||
@ -200,7 +200,7 @@
|
||||
<p class="text-sm">
|
||||
<FormatMessage key="admin.storage_template_user_label" values={{ label: $user.storageLabel || $user.id }}>
|
||||
{#snippet children({ message })}
|
||||
<code class="text-immich-primary dark:text-immich-dark-primary">{message}</code>
|
||||
<code class="text-primary">{message}</code>
|
||||
{/snippet}
|
||||
</FormatMessage>
|
||||
</p>
|
||||
@ -214,10 +214,7 @@
|
||||
<form autocomplete="off" class="flex flex-col" onsubmit={preventDefault(bubble('submit'))}>
|
||||
<div class="flex flex-col my-2">
|
||||
{#if templateOptions}
|
||||
<label
|
||||
class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm"
|
||||
for="preset-select"
|
||||
>
|
||||
<label class="font-medium text-primary text-sm" for="preset-select">
|
||||
{$t('preset')}
|
||||
</label>
|
||||
<select
|
||||
@ -257,7 +254,7 @@
|
||||
|
||||
{#if !minified}
|
||||
<div id="migration-info" class="mt-2 text-sm">
|
||||
<h3 class="text-base font-medium text-immich-primary dark:text-immich-dark-primary">{$t('notes')}</h3>
|
||||
<h3 class="text-base font-medium text-primary">{$t('notes')}</h3>
|
||||
<section class="flex flex-col gap-2">
|
||||
<p>
|
||||
<FormatMessage
|
||||
@ -265,7 +262,7 @@
|
||||
values={{ job: $t('admin.storage_template_migration_job') }}
|
||||
>
|
||||
{#snippet children({ message })}
|
||||
<a href={AppRoute.ADMIN_JOBS} class="text-immich-primary dark:text-immich-dark-primary">
|
||||
<a href={AppRoute.ADMIN_JOBS} class="text-primary">
|
||||
{message}
|
||||
</a>
|
||||
{/snippet}
|
||||
|
@ -27,7 +27,7 @@
|
||||
</div>
|
||||
<div class="flex gap-[40px]">
|
||||
<div>
|
||||
<p class="uppercase font-medium text-immich-primary dark:text-immich-dark-primary">{$t('year')}</p>
|
||||
<p class="uppercase font-medium text-primary">{$t('year')}</p>
|
||||
<ul>
|
||||
{#each options.yearOptions as yearFormat, index (index)}
|
||||
<li>{'{{'}{yearFormat}{'}}'} - {getLuxonExample(yearFormat)}</li>
|
||||
@ -36,7 +36,7 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="uppercase font-medium text-immich-primary dark:text-immich-dark-primary">{$t('month')}</p>
|
||||
<p class="uppercase font-medium text-primary">{$t('month')}</p>
|
||||
<ul>
|
||||
{#each options.monthOptions as monthFormat, index (index)}
|
||||
<li>{'{{'}{monthFormat}{'}}'} - {getLuxonExample(monthFormat)}</li>
|
||||
@ -45,7 +45,7 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="uppercase font-medium text-immich-primary dark:text-immich-dark-primary">{$t('week')}</p>
|
||||
<p class="uppercase font-medium text-primary">{$t('week')}</p>
|
||||
<ul>
|
||||
{#each options.weekOptions as weekFormat, index (index)}
|
||||
<li>{'{{'}{weekFormat}{'}}'} - {getLuxonExample(weekFormat)}</li>
|
||||
@ -54,7 +54,7 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="uppercase font-medium text-immich-primary dark:text-immich-dark-primary">{$t('day')}</p>
|
||||
<p class="uppercase font-medium text-primary">{$t('day')}</p>
|
||||
<ul>
|
||||
{#each options.dayOptions as dayFormat, index (index)}
|
||||
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
|
||||
@ -63,7 +63,7 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="uppercase font-medium text-immich-primary dark:text-immich-dark-primary">{$t('hour')}</p>
|
||||
<p class="uppercase font-medium text-primary">{$t('hour')}</p>
|
||||
<ul>
|
||||
{#each options.hourOptions as dayFormat, index (index)}
|
||||
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
|
||||
@ -72,7 +72,7 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="uppercase font-medium text-immich-primary dark:text-immich-dark-primary">{$t('minute')}</p>
|
||||
<p class="uppercase font-medium text-primary">{$t('minute')}</p>
|
||||
<ul>
|
||||
{#each options.minuteOptions as dayFormat, index (index)}
|
||||
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
|
||||
@ -81,7 +81,7 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="uppercase font-medium text-immich-primary dark:text-immich-dark-primary">{$t('second')}</p>
|
||||
<p class="uppercase font-medium text-primary">{$t('second')}</p>
|
||||
<ul>
|
||||
{#each options.secondOptions as dayFormat, index (index)}
|
||||
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
|
||||
|
@ -9,7 +9,7 @@
|
||||
<div class="p-4 mt-2 text-xs bg-gray-200 rounded-lg dark:bg-gray-700 dark:text-immich-dark-fg">
|
||||
<div class="flex gap-[50px]">
|
||||
<div>
|
||||
<p class="uppercase font-medium text-immich-primary dark:text-immich-dark-primary">{$t('filename')}</p>
|
||||
<p class="uppercase font-medium text-primary">{$t('filename')}</p>
|
||||
<ul>
|
||||
<li>{`{{filename}}`} - IMG_123</li>
|
||||
<li>{`{{ext}}`} - jpg</li>
|
||||
@ -17,14 +17,14 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="uppercase font-medium text-immich-primary dark:text-immich-dark-primary">{$t('filetype')}</p>
|
||||
<p class="uppercase font-medium text-primary">{$t('filetype')}</p>
|
||||
<ul>
|
||||
<li>{`{{filetype}}`} - VID or IMG</li>
|
||||
<li>{`{{filetypefull}}`} - VIDEO or IMAGE</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p class="uppercase font-medium text-immich-primary dark:text-immich-dark-primary">{$t('other')}</p>
|
||||
<p class="uppercase font-medium text-primary">{$t('other')}</p>
|
||||
<ul>
|
||||
<li>{`{{assetId}}`} - Asset ID</li>
|
||||
<li>{`{{assetIdShort}}`} - Asset ID (last 12 characters)</li>
|
||||
|
@ -48,7 +48,7 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggleAlbumGroupCollapsing(group.id)}
|
||||
class="w-full text-start mt-2 pt-2 pe-2 pb-2 rounded-md transition-colors cursor-pointer dark:text-immich-dark-fg hover:text-immich-primary dark:hover:text-immich-dark-primary hover:bg-subtle dark:hover:bg-immich-dark-gray"
|
||||
class="w-full text-start mt-2 pt-2 pe-2 pb-2 rounded-md transition-colors cursor-pointer dark:text-immich-dark-fg hover:text-primary hover:bg-subtle dark:hover:bg-immich-dark-gray"
|
||||
aria-expanded={!isCollapsed}
|
||||
>
|
||||
<Icon icon={mdiChevronRight} size="24" class="inline-block -mt-2.5 transition-all duration-250 {iconRotation}" />
|
||||
|
@ -60,7 +60,7 @@
|
||||
|
||||
<div class="mt-4">
|
||||
<p
|
||||
class="w-full leading-6 text-lg line-clamp-2 font-semibold text-black dark:text-white group-hover:text-immich-primary dark:group-hover:text-immich-dark-primary"
|
||||
class="w-full leading-6 text-lg line-clamp-2 font-semibold text-black dark:text-white group-hover:text-primary"
|
||||
data-testid="album-name"
|
||||
title={album.albumName}
|
||||
>
|
||||
|
@ -38,7 +38,7 @@
|
||||
<input
|
||||
use:shortcut={{ shortcut: { key: 'Enter' }, onShortcut: (e) => e.currentTarget.blur() }}
|
||||
onblur={handleUpdateName}
|
||||
class="w-[99%] mb-2 border-b-2 border-transparent text-2xl md:text-4xl lg:text-6xl text-immich-primary outline-none transition-all dark:text-immich-dark-primary {isOwned
|
||||
class="w-[99%] mb-2 border-b-2 border-transparent text-2xl md:text-4xl lg:text-6xl text-primary outline-none transition-all {isOwned
|
||||
? 'hover:border-gray-400'
|
||||
: 'hover:border-transparent'} focus:border-b-2 focus:border-immich-primary focus:outline-none bg-light dark:focus:border-immich-dark-primary dark:focus:bg-immich-dark-gray placeholder:text-primary/90"
|
||||
type="text"
|
||||
|
@ -64,9 +64,7 @@
|
||||
<Timeline enableRouting={true} {album} {timelineManager} {assetInteraction}>
|
||||
<section class="pt-8 md:pt-24 px-2 md:px-0">
|
||||
<!-- ALBUM TITLE -->
|
||||
<h1
|
||||
class="text-2xl md:text-4xl lg:text-6xl text-immich-primary outline-none transition-all dark:text-immich-dark-primary"
|
||||
>
|
||||
<h1 class="text-2xl md:text-4xl lg:text-6xl text-primary outline-none transition-all">
|
||||
{album.albumName}
|
||||
</h1>
|
||||
|
||||
|
@ -26,7 +26,7 @@
|
||||
|
||||
<table class="mt-2 w-full text-start">
|
||||
<thead
|
||||
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-primary"
|
||||
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray"
|
||||
>
|
||||
<tr class="flex w-full place-items-center p-2 md:p-5">
|
||||
{#each sortOptionsMetadata as option, index (index)}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import type { AssetAction } from '$lib/constants';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import type { AlbumResponseDto, AssetResponseDto, StackResponseDto } from '@immich/sdk';
|
||||
import type { AlbumResponseDto, AssetResponseDto, PersonResponseDto, StackResponseDto } from '@immich/sdk';
|
||||
|
||||
type ActionMap = {
|
||||
[AssetAction.ARCHIVE]: { asset: TimelineAsset };
|
||||
@ -18,6 +18,7 @@ type ActionMap = {
|
||||
[AssetAction.REMOVE_ASSET_FROM_STACK]: { stack: StackResponseDto | null; asset: AssetResponseDto };
|
||||
[AssetAction.SET_VISIBILITY_LOCKED]: { asset: TimelineAsset };
|
||||
[AssetAction.SET_VISIBILITY_TIMELINE]: { asset: TimelineAsset };
|
||||
[AssetAction.SET_PERSON_FEATURED_PHOTO]: { asset: AssetResponseDto; person: PersonResponseDto };
|
||||
};
|
||||
|
||||
export type Action = {
|
||||
|
@ -4,21 +4,36 @@
|
||||
notificationController,
|
||||
NotificationType,
|
||||
} from '$lib/components/shared-components/notification/notification';
|
||||
import { AssetAction } from '$lib/constants';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { updatePerson, type AssetResponseDto, type PersonResponseDto } from '@immich/sdk';
|
||||
import { mdiFaceManProfile } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { OnAction } from './action';
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
person: PersonResponseDto;
|
||||
onAction?: OnAction;
|
||||
}
|
||||
|
||||
let { asset, person }: Props = $props();
|
||||
let { asset, person, onAction }: Props = $props();
|
||||
|
||||
const handleSelectFeaturePhoto = async () => {
|
||||
try {
|
||||
await updatePerson({ id: person.id, personUpdateDto: { featureFaceAssetId: asset.id } });
|
||||
const updatedPerson = await updatePerson({
|
||||
id: person.id,
|
||||
personUpdateDto: { featureFaceAssetId: asset.id },
|
||||
});
|
||||
|
||||
person = { ...person, ...updatedPerson };
|
||||
|
||||
onAction?.({
|
||||
type: AssetAction.SET_PERSON_FEATURED_PHOTO,
|
||||
asset,
|
||||
person,
|
||||
});
|
||||
|
||||
notificationController.show({ message: $t('feature_photo_updated'), type: NotificationType.Info });
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_set_feature_photo'));
|
||||
|
@ -208,7 +208,7 @@
|
||||
<SetAlbumCoverAction {asset} {album} />
|
||||
{/if}
|
||||
{#if person}
|
||||
<SetFeaturedPhotoAction {asset} {person} />
|
||||
<SetFeaturedPhotoAction {asset} {person} {onAction} />
|
||||
{/if}
|
||||
{#if asset.type === AssetTypeEnum.Image && !isLocked}
|
||||
<SetProfilePictureAction {asset} />
|
||||
|
@ -22,6 +22,7 @@
|
||||
import {
|
||||
AssetJobName,
|
||||
AssetTypeEnum,
|
||||
getAssetInfo,
|
||||
getAllAlbums,
|
||||
getStack,
|
||||
runAssetJobs,
|
||||
@ -339,6 +340,11 @@
|
||||
stack = action.stack;
|
||||
break;
|
||||
}
|
||||
case AssetAction.SET_PERSON_FEATURED_PHOTO: {
|
||||
const assetInfo = await getAssetInfo({ id: asset.id });
|
||||
asset = { ...asset, people: assetInfo.people };
|
||||
break;
|
||||
}
|
||||
case AssetAction.KEEP_THIS_DELETE_OTHERS:
|
||||
case AssetAction.UNSTACK: {
|
||||
closeViewer();
|
||||
|
@ -40,8 +40,7 @@
|
||||
class="flex w-full text-start justify-between place-items-start gap-4 py-4"
|
||||
onclick={() => (isOwner ? (isShowChangeLocation = true) : null)}
|
||||
title={isOwner ? $t('edit_location') : ''}
|
||||
class:hover:dark:text-immich-dark-primary={isOwner}
|
||||
class:hover:text-immich-primary={isOwner}
|
||||
class:hover:text-primary={isOwner}
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
<div><Icon icon={mdiMapMarkerOutline} size="24" /></div>
|
||||
@ -72,7 +71,7 @@
|
||||
{:else if !asset.exifInfo?.city && isOwner}
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full text-start justify-between place-items-start gap-4 py-4 rounded-lg hover:dark:text-immich-dark-primary hover:text-immich-primary"
|
||||
class="flex w-full text-start justify-between place-items-start gap-4 py-4 rounded-lg hover:text-primary"
|
||||
onclick={() => (isShowChangeLocation = true)}
|
||||
title={$t('add_location')}
|
||||
>
|
||||
|
@ -279,8 +279,7 @@
|
||||
class="flex w-full text-start justify-between place-items-start gap-4 py-4"
|
||||
onclick={() => (isOwner ? (isShowChangeDate = true) : null)}
|
||||
title={isOwner ? $t('edit_date') : ''}
|
||||
class:hover:dark:text-immich-dark-primary={isOwner}
|
||||
class:hover:text-immich-primary={isOwner}
|
||||
class:hover:text-primary={isOwner}
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
<div>
|
||||
@ -362,10 +361,7 @@
|
||||
{/if}
|
||||
</p>
|
||||
{#if showAssetPath}
|
||||
<p
|
||||
class="text-xs opacity-50 break-all pb-2 hover:dark:text-immich-dark-primary hover:text-immich-primary"
|
||||
transition:slide={{ duration: 250 }}
|
||||
>
|
||||
<p class="text-xs opacity-50 break-all pb-2 hover:text-primary" transition:slide={{ duration: 250 }}>
|
||||
<a href={getAssetFolderHref(asset)} title={$t('go_to_folder')} class="whitespace-pre-wrap">
|
||||
{asset.originalPath}
|
||||
</a>
|
||||
@ -403,7 +399,7 @@
|
||||
...(asset.exifInfo?.model ? { model: asset.exifInfo.model } : {}),
|
||||
})}"
|
||||
title="{$t('search_for')} {asset.exifInfo.make || ''} {asset.exifInfo.model || ''}"
|
||||
class="hover:dark:text-immich-dark-primary hover:text-immich-primary"
|
||||
class="hover:text-primary"
|
||||
>
|
||||
{asset.exifInfo.make || ''}
|
||||
{asset.exifInfo.model || ''}
|
||||
@ -417,7 +413,7 @@
|
||||
<a
|
||||
href="{AppRoute.SEARCH}?{getMetadataSearchQuery({ lensModel: asset.exifInfo.lensModel })}"
|
||||
title="{$t('search_for')} {asset.exifInfo.lensModel}"
|
||||
class="hover:dark:text-immich-dark-primary hover:text-immich-primary line-clamp-1"
|
||||
class="hover:text-primary line-clamp-1"
|
||||
>
|
||||
{asset.exifInfo.lensModel}
|
||||
</a>
|
||||
|
@ -63,7 +63,7 @@
|
||||
<JobTileStatus color="success">{$t('active')}</JobTileStatus>
|
||||
{/if}
|
||||
<div class="flex flex-col gap-2 p-5 sm:p-7 md:p-9">
|
||||
<div class="flex items-center gap-4 text-xl font-semibold text-immich-primary dark:text-immich-dark-primary">
|
||||
<div class="flex items-center gap-4 text-xl font-semibold text-primary">
|
||||
<span class="flex items-center gap-2">
|
||||
<Icon {icon} size="1.25em" class="hidden shrink-0 sm:block" />
|
||||
<span class="uppercase">{title}</span>
|
||||
|
@ -11,7 +11,7 @@
|
||||
{#snippet children({ message })}
|
||||
<a
|
||||
href="{AppRoute.ADMIN_SETTINGS}?{QueryParameter.IS_OPEN}={OpenSettingQueryParameterValue.STORAGE_TEMPLATE}"
|
||||
class="text-immich-primary dark:text-immich-dark-primary"
|
||||
class="text-primary"
|
||||
>
|
||||
{message}
|
||||
</a>
|
||||
|
@ -36,7 +36,7 @@
|
||||
>
|
||||
<div>
|
||||
<div class="flex items-center justify-between gap-4 px-4 py-4">
|
||||
<h1 class="font-medium text-immich-primary dark:text-immich-dark-primary">
|
||||
<h1 class="font-medium text-primary">
|
||||
🚨 {$t('error_title')}
|
||||
</h1>
|
||||
<div class="flex justify-end">
|
||||
|
@ -36,10 +36,10 @@
|
||||
{#if title || icon}
|
||||
<div class="flex gap-2 items-center justify-center w-fit">
|
||||
{#if icon}
|
||||
<Icon {icon} size="30" class="text-immich-primary dark:text-immich-dark-primary" />
|
||||
<Icon {icon} size="30" class="text-primary" />
|
||||
{/if}
|
||||
{#if title}
|
||||
<p class="uppercase text-xl text-immich-primary dark:text-immich-dark-primary">
|
||||
<p class="uppercase text-xl text-primary">
|
||||
{title}
|
||||
</p>
|
||||
{/if}
|
||||
|
@ -1,16 +1,16 @@
|
||||
<script lang="ts">
|
||||
import ImmichLogo from '$lib/components/shared-components/immich-logo.svelte';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { OnboardingRole } from '$lib/models/onboarding-role';
|
||||
import { serverConfig } from '$lib/stores/server-config.store';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
let userRole = $derived($user.isAdmin && !$serverConfig.isOnboarded ? OnboardingRole.SERVER : OnboardingRole.USER);
|
||||
</script>
|
||||
|
||||
<div class="gap-4">
|
||||
<ImmichLogo noText class="h-[100px] mb-2" />
|
||||
<p class="font-medium mb-6 text-6xl text-immich-primary dark:text-immich-dark-primary">
|
||||
<p class="font-medium mb-6 text-6xl text-primary">
|
||||
{$t('onboarding_welcome_user', { values: { user: $user.name } })}
|
||||
</p>
|
||||
<p class="text-3xl pb-6 font-light">
|
||||
|
@ -7,7 +7,7 @@
|
||||
</svelte:head>
|
||||
|
||||
<section class="flex flex-col px-4 h-dvh w-dvw place-content-center place-items-center">
|
||||
<h1 class="py-10 text-4xl text-immich-primary dark:text-immich-dark-primary">Page not found :/</h1>
|
||||
<h1 class="py-10 text-4xl text-primary">Page not found :/</h1>
|
||||
{#if page.error?.message}
|
||||
<h2 class="text-xl text-immich-fg dark:text-immich-dark-fg">{page.error.message}</h2>
|
||||
{/if}
|
||||
|
@ -72,8 +72,8 @@
|
||||
class="relative h-dvh overflow-hidden px-6 max-md:pt-(--navbar-height-md) pt-(--navbar-height) sm:px-12 md:px-24 lg:px-40"
|
||||
>
|
||||
<div class="flex flex-col items-center justify-center mt-20">
|
||||
<div class="text-2xl font-bold text-immich-primary dark:text-immich-dark-primary">{$t('password_required')}</div>
|
||||
<div class="mt-4 text-lg text-immich-primary dark:text-immich-dark-primary">
|
||||
<div class="text-2xl font-bold text-primary">{$t('password_required')}</div>
|
||||
<div class="mt-4 text-lg text-primary">
|
||||
{$t('sharing_enter_password')}
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
|
@ -46,38 +46,38 @@
|
||||
<div class="mt-5 flex lg:hidden">
|
||||
<div class="flex flex-col justify-between rounded-3xl bg-subtle p-5 dark:bg-immich-dark-gray">
|
||||
<div class="flex flex-wrap gap-x-12">
|
||||
<div class="flex place-items-center gap-4 text-immich-primary dark:text-immich-dark-primary">
|
||||
<div class="flex place-items-center gap-4 text-primary">
|
||||
<Icon icon={mdiCameraIris} size="25" />
|
||||
<p class="uppercase">{$t('photos')}</p>
|
||||
</div>
|
||||
|
||||
<div class="relative text-center font-mono text-2xl font-semibold">
|
||||
<span class="text-[#DCDADA] dark:text-[#525252]">{zeros(stats.photos)}</span><span
|
||||
class="text-immich-primary dark:text-immich-dark-primary">{stats.photos}</span
|
||||
<span class="text-[#DCDADA] dark:text-[#525252]">{zeros(stats.photos)}</span><span class="text-primary"
|
||||
>{stats.photos}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-12">
|
||||
<div class="flex place-items-center gap-4 text-immich-primary dark:text-immich-dark-primary">
|
||||
<div class="flex place-items-center gap-4 text-primary">
|
||||
<Icon icon={mdiPlayCircle} size="25" />
|
||||
<p class="uppercase">{$t('videos')}</p>
|
||||
</div>
|
||||
|
||||
<div class="relative text-center font-mono text-2xl font-semibold">
|
||||
<span class="text-[#DCDADA] dark:text-[#525252]">{zeros(stats.videos)}</span><span
|
||||
class="text-immich-primary dark:text-immich-dark-primary">{stats.videos}</span
|
||||
<span class="text-[#DCDADA] dark:text-[#525252]">{zeros(stats.videos)}</span><span class="text-primary"
|
||||
>{stats.videos}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-7">
|
||||
<div class="flex place-items-center gap-4 text-immich-primary dark:text-immich-dark-primary">
|
||||
<div class="flex place-items-center gap-4 text-primary">
|
||||
<Icon icon={mdiChartPie} size="25" />
|
||||
<p class="uppercase">{$t('storage')}</p>
|
||||
</div>
|
||||
|
||||
<div class="relative flex text-center font-mono text-2xl font-semibold">
|
||||
<span class="text-[#DCDADA] dark:text-[#525252]">{zeros(statsUsage)}</span><span
|
||||
class="text-immich-primary dark:text-immich-dark-primary">{statsUsage}</span
|
||||
<span class="text-[#DCDADA] dark:text-[#525252]">{zeros(statsUsage)}</span><span class="text-primary"
|
||||
>{statsUsage}</span
|
||||
>
|
||||
<span class="my-auto ms-2 text-center text-base font-light text-gray-400">{statsUsageUnit}</span>
|
||||
</div>
|
||||
@ -90,7 +90,7 @@
|
||||
<p class="text-sm dark:text-immich-dark-fg uppercase">{$t('user_usage_detail')}</p>
|
||||
<table class="mt-5 w-full text-start">
|
||||
<thead
|
||||
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-primary"
|
||||
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray"
|
||||
>
|
||||
<tr class="flex w-full place-items-center">
|
||||
<th class="w-1/4 text-center text-sm font-medium">{$t('user')}</th>
|
||||
@ -116,7 +116,7 @@
|
||||
{#if user.quotaSizeInBytes !== null}
|
||||
/ {getByteUnitString(user.quotaSizeInBytes, $locale, 0)}
|
||||
{/if}
|
||||
<span class="text-immich-primary dark:text-immich-dark-primary">
|
||||
<span class="text-primary">
|
||||
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
|
||||
({(user.quotaSizeInBytes === 0 ? 1 : user.usage / user.quotaSizeInBytes).toLocaleString($locale, {
|
||||
style: 'percent',
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { getIntersectionObserverMock } from '$lib/__mocks__/intersection-observer.mock';
|
||||
import { getVisualViewportMock } from '$lib/__mocks__/visual-viewport.mock';
|
||||
import { fireEvent, render, screen } from '@testing-library/svelte';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { DateTime } from 'luxon';
|
||||
import ChangeDate from './change-date.svelte';
|
||||
@ -30,6 +30,13 @@ describe('ChangeDate component', () => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await waitFor(() => {
|
||||
// check that bits-ui body scroll-lock class is gone
|
||||
expect(document.body.style.pointerEvents).not.toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
test('should render correct values', () => {
|
||||
render(ChangeDate, { initialDate, initialTimeZone, onCancel, onConfirm });
|
||||
expect(getDateInput().value).toBe('2024-01-01T00:00');
|
||||
|
@ -367,11 +367,7 @@
|
||||
>
|
||||
{#snippet children({ feature }: { feature: Feature<Geometry, GeoJsonProperties> })}
|
||||
{#if useLocationPin}
|
||||
<Icon
|
||||
icon={mdiMapMarker}
|
||||
size="50px"
|
||||
class="dark:text-immich-dark-primary text-immich-primary -translate-y-[50%]"
|
||||
/>
|
||||
<Icon icon={mdiMapMarker} size="50px" class="text-primary -translate-y-[50%]" />
|
||||
{:else}
|
||||
<img
|
||||
src={getAssetThumbnailUrl(feature.properties?.id)}
|
||||
|
@ -55,7 +55,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-center text-lg font-medium text-immich-primary dark:text-immich-dark-primary">
|
||||
<p class="text-center text-lg font-medium text-primary">
|
||||
{$user.name}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 dark:text-immich-dark-fg">{$user.email}</p>
|
||||
@ -107,7 +107,7 @@
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="text-center mt-4 underline text-xs text-immich-primary dark:text-immich-dark-primary"
|
||||
class="text-center mt-4 underline text-xs text-primary"
|
||||
onclick={async () => {
|
||||
onClose();
|
||||
if (info) {
|
||||
|
@ -10,7 +10,7 @@
|
||||
<div
|
||||
class="border border-gray-300 dark:border-gray-800 w-[min(375px,100%)] p-8 rounded-3xl bg-gray-100 dark:bg-gray-900"
|
||||
>
|
||||
<div class="text-immich-primary dark:text-immich-dark-primary">
|
||||
<div class="text-primary">
|
||||
<Icon icon={mdiAccount} size="56" />
|
||||
<p class="font-semibold text-lg mt-1">{$t('purchase_individual_title')}</p>
|
||||
</div>
|
||||
|
@ -14,7 +14,7 @@
|
||||
</script>
|
||||
|
||||
<div class="m-auto w-3/4 text-center flex flex-col place-content-center place-items-center my-6">
|
||||
<Icon icon={mdiPartyPopper} class="text-immich-primary dark:text-immich-dark-primary" size="96" />
|
||||
<Icon icon={mdiPartyPopper} class="text-primary" size="96" />
|
||||
<p class="text-4xl mt-8 font-bold">{$t('purchase_activated_title')}</p>
|
||||
<p class="text-lg mt-6">{$t('purchase_activated_subtitle')}</p>
|
||||
|
||||
|
@ -10,7 +10,7 @@
|
||||
<div
|
||||
class="border border-gray-300 dark:border-gray-800 w-[min(375px,100%)] p-8 rounded-3xl bg-gray-100 dark:bg-gray-900"
|
||||
>
|
||||
<div class="text-immich-primary dark:text-immich-dark-primary">
|
||||
<div class="text-primary">
|
||||
<Icon icon={mdiServer} size="56" />
|
||||
<p class="font-semibold text-lg mt-1">{$t('purchase_server_title')}</p>
|
||||
</div>
|
||||
|
@ -102,7 +102,7 @@
|
||||
<button
|
||||
id={getId(0)}
|
||||
type="button"
|
||||
class="rounded-lg p-2 font-semibold text-immich-primary aria-selected:bg-immich-primary/25 hover:bg-immich-primary/25 dark:text-immich-dark-primary"
|
||||
class="rounded-lg p-2 font-semibold text-primary aria-selected:bg-immich-primary/25 hover:bg-immich-primary/25"
|
||||
role="option"
|
||||
onclick={() => handleClearAll()}
|
||||
tabindex="-1"
|
||||
|
@ -78,9 +78,9 @@
|
||||
<div>
|
||||
<div class="flex gap-2 place-items-center">
|
||||
{#if icon}
|
||||
<Icon {icon} class="text-immich-primary dark:text-immich-dark-primary" size="24" aria-hidden />
|
||||
<Icon {icon} class="text-primary" size="24" aria-hidden />
|
||||
{/if}
|
||||
<h2 class="font-medium text-immich-primary dark:text-immich-dark-primary">
|
||||
<h2 class="font-medium text-primary">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
|
@ -16,13 +16,9 @@
|
||||
<div class="mt-8 flex justify-between gap-2">
|
||||
<div class="left">
|
||||
{#if showResetToDefault}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onReset({ default: true })}
|
||||
class="bg-none text-sm font-medium text-immich-primary hover:text-immich-primary/75 dark:text-immich-dark-primary hover:dark:text-immich-dark-primary/75"
|
||||
<Button variant="ghost" shape="round" size="small" onclick={() => onReset({ default: true })}
|
||||
>{$t('reset_to_default')}</Button
|
||||
>
|
||||
{$t('reset_to_default')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
@ -31,7 +31,7 @@
|
||||
|
||||
<div class="mb-4 w-full">
|
||||
<div class="flex h-[26px] place-items-center gap-1">
|
||||
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="{name}-select">
|
||||
<label class="font-medium text-primary text-sm" for="{name}-select">
|
||||
{label}
|
||||
</label>
|
||||
|
||||
|
@ -1,9 +1,9 @@
|
||||
<script lang="ts">
|
||||
import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
import { fly } from 'svelte/transition';
|
||||
import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
@ -31,7 +31,7 @@
|
||||
<div class="grid grid-cols-2">
|
||||
<div>
|
||||
<div class="flex h-[26px] place-items-center gap-1">
|
||||
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for={title}>
|
||||
<label class="font-medium text-primary text-sm" for={title}>
|
||||
{title}
|
||||
</label>
|
||||
{#if isEdited}
|
||||
|
@ -29,7 +29,7 @@
|
||||
<div class="flex place-items-center justify-between">
|
||||
<div>
|
||||
<div class="flex h-[26px] place-items-center gap-1">
|
||||
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for={title}>
|
||||
<label class="font-medium text-primary text-sm" for={title}>
|
||||
{title}
|
||||
</label>
|
||||
{#if isEdited}
|
||||
|
@ -81,9 +81,7 @@
|
||||
|
||||
<div class="mb-4 w-full">
|
||||
<div class="flex place-items-center gap-1">
|
||||
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm min-h-6 uppercase" for={label}
|
||||
>{label}</label
|
||||
>
|
||||
<label class="font-medium text-primary text-sm min-h-6 uppercase" for={label}>{label}</label>
|
||||
{#if required}
|
||||
<div class="text-red-400">*</div>
|
||||
{/if}
|
||||
|
@ -40,9 +40,7 @@
|
||||
|
||||
<div class="mb-4 w-full">
|
||||
<div class="flex h-[26px] place-items-center gap-1">
|
||||
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="{name}-select"
|
||||
>{label}</label
|
||||
>
|
||||
<label class="font-medium text-primary text-sm" for="{name}-select">{label}</label>
|
||||
|
||||
{#if isEdited}
|
||||
<div
|
||||
|
@ -35,7 +35,7 @@
|
||||
<div class="flex place-items-center justify-between">
|
||||
<div class="me-2">
|
||||
<div class="flex h-[26px] place-items-center gap-1">
|
||||
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for={sliderId}>
|
||||
<label class="font-medium text-primary text-sm" for={sliderId}>
|
||||
{title}
|
||||
</label>
|
||||
{#if isEdited}
|
||||
|
@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
@ -31,7 +31,7 @@
|
||||
|
||||
<div class="mb-4 w-full">
|
||||
<div class="flex h-[26px] place-items-center gap-1">
|
||||
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for={label}>{label}</label>
|
||||
<label class="font-medium text-primary text-sm" for={label}>{label}</label>
|
||||
{#if required}
|
||||
<div class="text-red-400">*</div>
|
||||
{/if}
|
||||
|
@ -38,7 +38,7 @@
|
||||
{#if showSettingDescription}
|
||||
<div>
|
||||
<div class="flex h-[26px] place-items-center gap-1">
|
||||
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for={$t('language')}>
|
||||
<label class="font-medium text-primary text-sm" for={$t('language')}>
|
||||
{$t('language')}
|
||||
</label>
|
||||
</div>
|
||||
|
@ -96,17 +96,13 @@
|
||||
<div class="h-6 w-6">
|
||||
<ImmichLogo noText class="h-[24px]" />
|
||||
</div>
|
||||
<p class="flex text-immich-primary dark:text-immich-dark-primary font-medium">
|
||||
<p class="flex text-primary font-medium">
|
||||
{$t('purchase_button_buy_immich')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Icon
|
||||
icon={mdiInformationOutline}
|
||||
class="hidden sidebar:flex text-immich-primary dark:text-immich-dark-primary font-medium"
|
||||
size="18"
|
||||
/>
|
||||
<Icon icon={mdiInformationOutline} class="hidden sidebar:flex text-primary font-medium" size="18" />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@ -142,7 +138,7 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h1 class="text-lg font-medium my-3 dark:text-immich-dark-primary text-immich-primary">
|
||||
<h1 class="text-lg font-medium my-3 text-primary">
|
||||
{$t('purchase_panel_title')}
|
||||
</h1>
|
||||
|
||||
|
@ -57,9 +57,7 @@
|
||||
draggable="false"
|
||||
aria-current={isSelected ? 'page' : undefined}
|
||||
class="flex w-full place-items-center gap-4 rounded-e-full py-3 transition-[padding] delay-100 duration-100 hover:cursor-pointer hover:bg-subtle hover:text-immich-primary dark:text-immich-dark-fg dark:hover:bg-immich-dark-gray dark:hover:text-immich-dark-primary
|
||||
{isSelected
|
||||
? 'bg-immich-primary/10 text-immich-primary hover:bg-immich-primary/10 dark:bg-immich-dark-primary/10 dark:text-immich-dark-primary'
|
||||
: ''}"
|
||||
{isSelected ? 'bg-immich-primary/10 text-primary hover:bg-immich-primary/10 dark:bg-immich-dark-primary/10' : ''}"
|
||||
>
|
||||
<div class="flex w-full place-items-center gap-4 ps-5 overflow-hidden truncate">
|
||||
<Icon {icon} size="1.5em" class="shrink-0" flipped={flippedLogo} aria-hidden />
|
||||
|
@ -1,11 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { shortcut } from '$lib/actions/shortcut';
|
||||
import { defaultLang, langs, Theme } from '$lib/constants';
|
||||
import { themeManager } from '$lib/managers/theme-manager.svelte';
|
||||
import { lang } from '$lib/stores/preferences.store';
|
||||
import { ThemeSwitcher } from '@immich/ui';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
const handleToggleTheme = () => {
|
||||
if (themeManager.theme.system) {
|
||||
return;
|
||||
}
|
||||
|
||||
themeManager.toggleTheme();
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:window use:shortcut={{ shortcut: { key: 't', alt: true }, onShortcut: () => handleToggleTheme() }} />
|
||||
|
||||
{#if !themeManager.theme.system}
|
||||
{#await langs
|
||||
.find((item) => item.code === get(lang))
|
||||
|
@ -51,9 +51,7 @@
|
||||
/>
|
||||
</li>
|
||||
{#each parents as parent (parent)}
|
||||
<li
|
||||
class="flex gap-2 items-center font-mono text-sm text-nowrap text-immich-primary dark:text-immich-dark-primary"
|
||||
>
|
||||
<li class="flex gap-2 items-center font-mono text-sm text-nowrap text-primary">
|
||||
<Icon icon={mdiChevronRight} class="text-gray-500 dark:text-gray-300" size="16" aria-hidden />
|
||||
<a class="underline hover:font-semibold whitespace-pre-wrap" href={getLink(parent.path)}>
|
||||
{parent.value}
|
||||
@ -61,9 +59,7 @@
|
||||
</li>
|
||||
{/each}
|
||||
|
||||
<li
|
||||
class="flex gap-2 items-center font-mono text-sm text-nowrap text-immich-primary dark:text-immich-dark-primary"
|
||||
>
|
||||
<li class="flex gap-2 items-center font-mono text-sm text-nowrap text-primary">
|
||||
<Icon icon={mdiChevronRight} class="text-gray-500 dark:text-gray-300" size="16" aria-hidden />
|
||||
<p class="cursor-default whitespace-pre-wrap">{node.value}</p>
|
||||
</li>
|
||||
|
@ -23,7 +23,7 @@
|
||||
title={item.value}
|
||||
type="button"
|
||||
>
|
||||
<Icon {icon} class="text-immich-primary dark:text-immich-dark-primary" size="64" />
|
||||
<Icon {icon} class="text-primary" size="64" />
|
||||
<p class="text-sm dark:text-gray-200 text-nowrap text-ellipsis overflow-clip w-full whitespace-pre-wrap">
|
||||
{item.value}
|
||||
</p>
|
||||
|
@ -26,7 +26,7 @@
|
||||
<a
|
||||
href={getLink(node.path)}
|
||||
title={node.value}
|
||||
class={`flex grow place-items-center ps-2 py-1 text-sm rounded-lg hover:bg-slate-200 dark:hover:bg-slate-800 hover:font-semibold ${isTarget ? 'bg-slate-100 dark:bg-slate-700 font-semibold text-immich-primary dark:text-immich-dark-primary' : 'dark:text-gray-200'}`}
|
||||
class={`flex grow place-items-center ps-2 py-1 text-sm rounded-lg hover:bg-slate-200 dark:hover:bg-slate-800 hover:font-semibold ${isTarget ? 'bg-slate-100 dark:bg-slate-700 font-semibold text-primary' : 'dark:text-gray-200'}`}
|
||||
data-sveltekit-keepfocus
|
||||
>
|
||||
{#if node.size > 0}
|
||||
@ -37,7 +37,7 @@
|
||||
<div class={node.size === 0 ? 'ml-[1.5em] ' : ''}>
|
||||
<Icon
|
||||
icon={isActive ? icons.active : icons.default}
|
||||
class={isActive ? 'text-immich-primary dark:text-immich-dark-primary' : 'text-gray-400'}
|
||||
class={isActive ? 'text-primary' : 'text-gray-400'}
|
||||
color={node.color}
|
||||
size="20"
|
||||
/>
|
||||
|
@ -61,9 +61,7 @@
|
||||
</div>
|
||||
|
||||
<div class="text-sm pb-2">
|
||||
<p
|
||||
class="flex place-items-center gap-2 text-immich-primary dark:text-immich-dark-primary break-all uppercase"
|
||||
>
|
||||
<p class="flex place-items-center gap-2 text-primary break-all uppercase">
|
||||
{#if link.type === SharedLinkType.Album}
|
||||
{link.album?.albumName}
|
||||
{:else if link.type === SharedLinkType.Individual}
|
||||
|
@ -38,9 +38,7 @@
|
||||
|
||||
<ControlAppBar onClose={clearSelect} {forceDark} backIcon={mdiClose} tailwindClasses="bg-white shadow-md">
|
||||
{#snippet leading()}
|
||||
<div
|
||||
class="font-medium {forceDark ? 'text-immich-dark-primary' : 'text-immich-primary dark:text-immich-dark-primary'}"
|
||||
>
|
||||
<div class="font-medium {forceDark ? 'text-immich-dark-primary' : 'text-primary'}">
|
||||
<p class="block sm:hidden">{assets.length}</p>
|
||||
<p class="hidden sm:block">{$t('selected_count', { values: { count: assets.length } })}</p>
|
||||
</div>
|
||||
|
@ -12,6 +12,7 @@
|
||||
setFocusTo as setFocusToInit,
|
||||
} from '$lib/components/timeline/actions/focus-actions';
|
||||
import { AppRoute, AssetAction } from '$lib/constants';
|
||||
import HotModuleReload from '$lib/elements/HotModuleReload.svelte';
|
||||
import Portal from '$lib/elements/Portal.svelte';
|
||||
import Skeleton from '$lib/elements/Skeleton.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
@ -223,45 +224,33 @@
|
||||
complete.then(completeNav, completeNav);
|
||||
});
|
||||
|
||||
const hmrSupport = () => {
|
||||
// when hmr happens, skeleton is initialized to true by default
|
||||
// normally, loading timeline is part of a navigation event, and the completion of
|
||||
// that event triggers a scroll-to-asset, if necessary, when then clears the skeleton.
|
||||
// this handler will run the navigation/scroll-to-asset handler when hmr is performed,
|
||||
// preventing skeleton from showing after hmr
|
||||
if (import.meta && import.meta.hot) {
|
||||
const afterApdate = (payload: UpdatePayload) => {
|
||||
const assetGridUpdate = payload.updates.some(
|
||||
(update) => update.path.endsWith('Timeline.svelte') || update.path.endsWith('assets-store.ts'),
|
||||
);
|
||||
const handleAfterUpdate = (payload: UpdatePayload) => {
|
||||
const timelineUpdate = payload.updates.some(
|
||||
(update) => update.path.endsWith('Timeline.svelte') || update.path.endsWith('assets-store.ts'),
|
||||
);
|
||||
|
||||
if (assetGridUpdate) {
|
||||
setTimeout(() => {
|
||||
const asset = $page.url.searchParams.get('at');
|
||||
if (asset) {
|
||||
$gridScrollTarget = { at: asset };
|
||||
void navigate(
|
||||
{ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget },
|
||||
{ replaceState: true, forceNavigate: true },
|
||||
);
|
||||
} else {
|
||||
scrollToTop();
|
||||
}
|
||||
showSkeleton = false;
|
||||
}, 500);
|
||||
if (timelineUpdate) {
|
||||
setTimeout(() => {
|
||||
const asset = $page.url.searchParams.get('at');
|
||||
if (asset) {
|
||||
$gridScrollTarget = { at: asset };
|
||||
void navigate(
|
||||
{ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget },
|
||||
{ replaceState: true, forceNavigate: true },
|
||||
);
|
||||
} else {
|
||||
scrollToTop();
|
||||
}
|
||||
};
|
||||
import.meta.hot?.on('vite:afterUpdate', afterApdate);
|
||||
import.meta.hot?.on('vite:beforeUpdate', (payload) => {
|
||||
const assetGridUpdate = payload.updates.some((update) => update.path.endsWith('Timeline.svelte'));
|
||||
if (assetGridUpdate) {
|
||||
timelineManager.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
return () => import.meta.hot?.off('vite:afterUpdate', afterApdate);
|
||||
showSkeleton = false;
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBeforeUpdate = (payload: UpdatePayload) => {
|
||||
const timelineUpdate = payload.updates.some((update) => update.path.endsWith('Timeline.svelte'));
|
||||
if (timelineUpdate) {
|
||||
timelineManager.destroy();
|
||||
}
|
||||
return () => void 0;
|
||||
};
|
||||
|
||||
const updateIsScrolling = () => (timelineManager.scrolling = true);
|
||||
@ -287,10 +276,6 @@
|
||||
if (!enableRouting) {
|
||||
showSkeleton = false;
|
||||
}
|
||||
const disposeHmr = hmrSupport();
|
||||
return () => {
|
||||
disposeHmr();
|
||||
};
|
||||
});
|
||||
|
||||
const getMaxScrollPercent = () => {
|
||||
@ -833,6 +818,8 @@
|
||||
|
||||
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} onselectstart={onSelectStart} use:shortcuts={shortcutList} />
|
||||
|
||||
<HotModuleReload onAfterUpdate={handleAfterUpdate} onBeforeUpdate={handleBeforeUpdate} />
|
||||
|
||||
{#if isShowDeleteConfirmation}
|
||||
<DeleteAssetDialog
|
||||
size={idsSelectedAssets.length}
|
||||
|
@ -31,7 +31,7 @@
|
||||
</script>
|
||||
|
||||
<div class="flex w-full flex-row">
|
||||
<div class="hidden items-center justify-center pe-2 text-immich-primary dark:text-immich-dark-primary sm:flex">
|
||||
<div class="hidden items-center justify-center pe-2 text-primary sm:flex">
|
||||
{#if device.deviceOS === 'Android'}
|
||||
<Icon icon={mdiAndroid} size="40" />
|
||||
{:else if device.deviceOS === 'iOS' || device.deviceOS === 'macOS'}
|
||||
|
@ -56,7 +56,7 @@
|
||||
<section class="my-4">
|
||||
{#if currentDevice}
|
||||
<div class="mb-6">
|
||||
<h3 class="uppercase mb-2 text-xs font-medium text-immich-primary dark:text-immich-dark-primary">
|
||||
<h3 class="uppercase mb-2 text-xs font-medium text-primary">
|
||||
{$t('current_device')}
|
||||
</h3>
|
||||
<DeviceCard device={currentDevice} />
|
||||
@ -64,7 +64,7 @@
|
||||
{/if}
|
||||
{#if otherDevices.length > 0}
|
||||
<div class="mb-6">
|
||||
<h3 class="uppercase mb-2 text-xs font-medium text-immich-primary dark:text-immich-dark-primary">
|
||||
<h3 class="uppercase mb-2 text-xs font-medium text-primary">
|
||||
{$t('other_devices')}
|
||||
</h3>
|
||||
{#each otherDevices as device, index (device.id)}
|
||||
@ -74,7 +74,7 @@
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
<h3 class="uppercase mb-2 text-xs font-medium text-immich-primary dark:text-immich-dark-primary">
|
||||
<h3 class="uppercase mb-2 text-xs font-medium text-primary">
|
||||
{$t('log_out_all_devices')}
|
||||
</h3>
|
||||
<div class="flex justify-end">
|
||||
|
@ -101,7 +101,7 @@
|
||||
{#if keys.length > 0}
|
||||
<table class="w-full text-start">
|
||||
<thead
|
||||
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-primary"
|
||||
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray"
|
||||
>
|
||||
<tr class="flex w-full place-items-center">
|
||||
<th class="w-1/4 text-center text-sm font-medium">{$t('name')}</th>
|
||||
|
@ -122,10 +122,10 @@
|
||||
<div
|
||||
class="bg-gray-50 border border-immich-dark-primary/20 dark:bg-immich-dark-primary/15 p-6 pe-12 rounded-xl flex place-content-center gap-4"
|
||||
>
|
||||
<Icon icon={mdiKey} size="56" class="text-immich-primary dark:text-immich-dark-primary" />
|
||||
<Icon icon={mdiKey} size="56" class="text-primary" />
|
||||
|
||||
<div>
|
||||
<p class="text-immich-primary dark:text-immich-dark-primary font-semibold text-lg">
|
||||
<p class="text-primary font-semibold text-lg">
|
||||
{$t('purchase_server_title')}
|
||||
</p>
|
||||
|
||||
@ -154,10 +154,10 @@
|
||||
<div
|
||||
class="bg-gray-50 border border-immich-dark-primary/20 dark:bg-immich-dark-primary/15 p-6 pe-12 rounded-xl flex place-content-center gap-4"
|
||||
>
|
||||
<Icon icon={mdiKey} size="56" class="text-immich-primary dark:text-immich-dark-primary" />
|
||||
<Icon icon={mdiKey} size="56" class="text-primary" />
|
||||
|
||||
<div>
|
||||
<p class="text-immich-primary dark:text-immich-dark-primary font-semibold text-lg">
|
||||
<p class="text-primary font-semibold text-lg">
|
||||
{$t('purchase_individual_title')}
|
||||
</p>
|
||||
{#if $user.license?.activatedAt}
|
||||
|
@ -71,7 +71,7 @@
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-start mt-4">
|
||||
<thead
|
||||
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-primary"
|
||||
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray"
|
||||
>
|
||||
<tr class="flex w-full place-items-center text-sm font-medium text-center">
|
||||
<th class="w-1/4">{$t('view_name')}</th>
|
||||
@ -94,9 +94,7 @@
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-start mt-4">
|
||||
<thead
|
||||
class="mb-4 flex h-12 w-full rounded-md border text-immich-primary dark:border-immich-dark-gray bg-subtle dark:text-immich-dark-primary"
|
||||
>
|
||||
<thead class="mb-4 flex h-12 w-full rounded-md border text-primary dark:border-immich-dark-gray bg-subtle">
|
||||
<tr class="flex w-full place-items-center text-sm font-medium text-center">
|
||||
<th class="w-1/2">{$t('owned')}</th>
|
||||
<th class="w-1/2">{$t('shared')}</th>
|
||||
|
@ -16,7 +16,7 @@
|
||||
|
||||
{#each links as link (link.href)}
|
||||
<a href={link.href} class="w-full hover:bg-gray-100 dark:hover:bg-immich-dark-gray flex items-center gap-4 p-4">
|
||||
<span><Icon icon={link.icon} class="text-immich-primary dark:text-immich-dark-primary" size="24" /> </span>
|
||||
<span><Icon icon={link.icon} class="text-primary" size="24" /> </span>
|
||||
{link.label}
|
||||
</a>
|
||||
{/each}
|
||||
|
@ -14,6 +14,7 @@ export enum AssetAction {
|
||||
REMOVE_ASSET_FROM_STACK = 'remove-asset-from-stack',
|
||||
SET_VISIBILITY_LOCKED = 'set-visibility-locked',
|
||||
SET_VISIBILITY_TIMELINE = 'set-visibility-timeline',
|
||||
SET_PERSON_FEATURED_PHOTO = 'set-person-featured-photo',
|
||||
}
|
||||
|
||||
export enum AppRoute {
|
||||
|
@ -121,10 +121,10 @@
|
||||
onclick={() => !renderedOption.disabled && handleSelectOption(option)}
|
||||
>
|
||||
{#if isEqual(selectedOption, option)}
|
||||
<div class="text-immich-primary dark:text-immich-dark-primary">
|
||||
<div class="text-primary">
|
||||
<Icon icon={mdiCheck} />
|
||||
</div>
|
||||
<p class="justify-self-start text-immich-primary dark:text-immich-dark-primary">
|
||||
<p class="justify-self-start text-primary">
|
||||
{renderedOption.title}
|
||||
</p>
|
||||
{:else}
|
||||
|
36
web/src/lib/elements/HotModuleReload.svelte
Normal file
36
web/src/lib/elements/HotModuleReload.svelte
Normal file
@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import type { UpdatePayload } from 'vite';
|
||||
|
||||
type Props = {
|
||||
onBeforeUpdate?: (payload: UpdatePayload) => void;
|
||||
onAfterUpdate?: (payload: UpdatePayload) => void;
|
||||
};
|
||||
|
||||
let { onBeforeUpdate, onAfterUpdate }: Props = $props();
|
||||
|
||||
const unsubscribes: (() => void)[] = [];
|
||||
|
||||
onMount(() => {
|
||||
const hot = import.meta.hot;
|
||||
if (!hot) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (onBeforeUpdate) {
|
||||
hot.on('vite:beforeUpdate', onBeforeUpdate);
|
||||
unsubscribes.push(() => hot.off('vite:beforeUpdate', onBeforeUpdate));
|
||||
}
|
||||
|
||||
if (onAfterUpdate) {
|
||||
hot.on('vite:afterUpdate', onAfterUpdate);
|
||||
unsubscribes.push(() => hot.off('vite:afterUpdate', onAfterUpdate));
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
for (const unsubscribe of unsubscribes) {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
</script>
|
@ -57,7 +57,7 @@
|
||||
|
||||
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
|
||||
<fieldset
|
||||
class="text-immich-primary dark:text-immich-dark-primary w-fit cursor-default"
|
||||
class="text-primary w-fit cursor-default"
|
||||
onmouseleave={() => setHoverRating(0)}
|
||||
use:focusOutside={{ onFocusOut: reset }}
|
||||
use:shortcuts={[
|
||||
@ -114,7 +114,7 @@
|
||||
ratingSelection = 0;
|
||||
handleSelect(ratingSelection);
|
||||
}}
|
||||
class="cursor-pointer text-xs text-immich-primary dark:text-immich-dark-primary"
|
||||
class="cursor-pointer text-xs text-primary"
|
||||
>
|
||||
{$t('rating_clear')}
|
||||
</button>
|
||||
|
@ -140,7 +140,7 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleRemoveUser(user)}
|
||||
class="text-sm font-medium text-immich-primary transition-colors hover:text-immich-primary/75 dark:text-immich-dark-primary"
|
||||
class="text-sm font-medium text-primary transition-colors hover:text-immich-primary/75"
|
||||
>{$t('leave')}</button
|
||||
>
|
||||
{/if}
|
||||
|
@ -14,7 +14,7 @@
|
||||
|
||||
<Modal title={$t('api_key')} icon={mdiKeyVariant} {onClose} size="small">
|
||||
<ModalBody>
|
||||
<div class="text-immich-primary dark:text-immich-dark-primary">
|
||||
<div class="text-primary">
|
||||
<p class="text-sm dark:text-immich-dark-fg">
|
||||
{$t('api_key_description')}
|
||||
</p>
|
||||
|
@ -20,10 +20,7 @@
|
||||
<div>
|
||||
<a href="https://{info.version}.archive.immich.app/docs/overview/introduction" target="_blank" rel="noreferrer">
|
||||
<Icon icon={mdiInformationOutline} size="1.5em" class="inline-block" />
|
||||
<p
|
||||
class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm underline inline-block"
|
||||
id="documentation-label"
|
||||
>
|
||||
<p class="font-medium text-primary text-sm underline inline-block" id="documentation-label">
|
||||
{$t('documentation')}
|
||||
</p>
|
||||
</a>
|
||||
@ -32,10 +29,7 @@
|
||||
<div>
|
||||
<a href="https://github.com/immich-app/immich/" target="_blank" rel="noreferrer">
|
||||
<Icon icon={mdiGithub} size="1.5em" class="inline-block" />
|
||||
<p
|
||||
class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm underline inline-block"
|
||||
id="github-label"
|
||||
>
|
||||
<p class="font-medium text-primary text-sm underline inline-block" id="github-label">
|
||||
{$t('source')}
|
||||
</p>
|
||||
</a>
|
||||
@ -44,10 +38,7 @@
|
||||
<div>
|
||||
<a href="https://discord.immich.app" target="_blank" rel="noreferrer">
|
||||
<Icon icon={siDiscord} class="inline-block" size="1.5em" />
|
||||
<p
|
||||
class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm underline inline-block"
|
||||
id="github-label"
|
||||
>
|
||||
<p class="font-medium text-primary text-sm underline inline-block" id="github-label">
|
||||
{$t('discord')}
|
||||
</p>
|
||||
</a>
|
||||
@ -56,10 +47,7 @@
|
||||
<div>
|
||||
<a href="https://github.com/immich-app/immich/issues/new/choose" target="_blank" rel="noreferrer">
|
||||
<Icon icon={mdiBugOutline} size="1.5em" class="inline-block" />
|
||||
<p
|
||||
class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm underline inline-block"
|
||||
id="github-label"
|
||||
>
|
||||
<p class="font-medium text-primary text-sm underline inline-block" id="github-label">
|
||||
{$t('bugs_and_feature_requests')}
|
||||
</p>
|
||||
</a>
|
||||
@ -75,10 +63,7 @@
|
||||
<div>
|
||||
<a href={info.thirdPartyDocumentationUrl} target="_blank" rel="noreferrer">
|
||||
<Icon icon={mdiInformationOutline} size="1.5em" class="inline-block" />
|
||||
<p
|
||||
class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm underline inline-block"
|
||||
id="documentation-label"
|
||||
>
|
||||
<p class="font-medium text-primary text-sm underline inline-block" id="documentation-label">
|
||||
{$t('documentation')}
|
||||
</p>
|
||||
</a>
|
||||
@ -89,10 +74,7 @@
|
||||
<div>
|
||||
<a href={info.thirdPartySourceUrl} target="_blank" rel="noreferrer">
|
||||
<Icon icon={mdiGit} size="1.5em" class="inline-block" />
|
||||
<p
|
||||
class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm underline inline-block"
|
||||
id="github-label"
|
||||
>
|
||||
<p class="font-medium text-primary text-sm underline inline-block" id="github-label">
|
||||
{$t('source')}
|
||||
</p>
|
||||
</a>
|
||||
@ -103,10 +85,7 @@
|
||||
<div>
|
||||
<a href={info.thirdPartySupportUrl} target="_blank" rel="noreferrer">
|
||||
<Icon icon={mdiFaceAgent} class="inline-block" size="1.5em" />
|
||||
<p
|
||||
class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm underline inline-block"
|
||||
id="github-label"
|
||||
>
|
||||
<p class="font-medium text-primary text-sm underline inline-block" id="github-label">
|
||||
{$t('support')}
|
||||
</p>
|
||||
</a>
|
||||
@ -117,10 +96,7 @@
|
||||
<div>
|
||||
<a href={info.thirdPartyBugFeatureUrl} target="_blank" rel="noreferrer">
|
||||
<Icon icon={mdiBugOutline} size="1.5em" class="inline-block" />
|
||||
<p
|
||||
class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm underline inline-block"
|
||||
id="github-label"
|
||||
>
|
||||
<p class="font-medium text-primary text-sm underline inline-block" id="github-label">
|
||||
{$t('bugs_and_feature_requests')}
|
||||
</p>
|
||||
</a>
|
||||
|
@ -37,7 +37,7 @@
|
||||
|
||||
<Modal title={$t('set_date_of_birth')} icon={mdiCake} {onClose} size="small">
|
||||
<ModalBody>
|
||||
<div class="text-immich-primary dark:text-immich-dark-primary">
|
||||
<div class="text-primary">
|
||||
<p class="text-sm dark:text-immich-dark-fg">
|
||||
{$t('birthdate_set_description')}
|
||||
</p>
|
||||
|
@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import ServerAboutItem from '$lib/components/ServerAboutItem.svelte';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { type ServerAboutResponseDto, type ServerVersionHistoryResponseDto } from '@immich/sdk';
|
||||
import { Icon, Modal, ModalBody } from '@immich/ui';
|
||||
import { mdiAlert } from '@mdi/js';
|
||||
import { Alert, Label, Modal, ModalBody } from '@immich/ui';
|
||||
import { DateTime } from 'luxon';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
@ -17,158 +17,61 @@
|
||||
|
||||
<Modal title={$t('about')} {onClose}>
|
||||
<ModalBody>
|
||||
<div class="flex flex-col sm:grid sm:grid-cols-2 gap-1 text-immich-primary dark:text-immich-dark-primary">
|
||||
<div>
|
||||
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="version-desc"
|
||||
>Immich</label
|
||||
>
|
||||
<div>
|
||||
<a
|
||||
href={info.versionUrl}
|
||||
class="underline text-sm immich-form-label"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
id="version-desc"
|
||||
>
|
||||
{info.version}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col sm:grid sm:grid-cols-2 gap-4">
|
||||
{#if info.sourceRef === 'main' && info.repository === 'immich-app/immich'}
|
||||
<Alert color="warning" title={$t('main_branch_warning')} class="col-span-full" size="small" />
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="ffmpeg-desc"
|
||||
>ExifTool</label
|
||||
>
|
||||
<p class="immich-form-label pb-2 text-sm" id="ffmpeg-desc">
|
||||
{info.exiftool}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="nodejs-desc"
|
||||
>Node.js</label
|
||||
>
|
||||
<p class="immich-form-label pb-2 text-sm" id="nodejs-desc">
|
||||
{info.nodejs}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="vips-desc"
|
||||
>Libvips</label
|
||||
>
|
||||
<p class="immich-form-label pb-2 text-sm" id="vips-desc">
|
||||
{info.libvips}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class={(info.imagemagick?.length || 0) > 10 ? 'col-span-2' : ''}>
|
||||
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="imagemagick-desc"
|
||||
>ImageMagick</label
|
||||
>
|
||||
<p class="immich-form-label pb-2 text-sm" id="imagemagick-desc">
|
||||
{info.imagemagick}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class={(info.ffmpeg?.length || 0) > 10 ? 'col-span-2' : ''}>
|
||||
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="ffmpeg-desc"
|
||||
>FFmpeg</label
|
||||
>
|
||||
<p class="immich-form-label pb-2 text-sm" id="ffmpeg-desc">
|
||||
{info.ffmpeg}
|
||||
</p>
|
||||
</div>
|
||||
<ServerAboutItem id="immich" title="Immich" version={info.version} versionHref={info.versionUrl} />
|
||||
<ServerAboutItem id="exif" title="ExifTool" version={info.exiftool} />
|
||||
<ServerAboutItem id="nodejs" title="Node.js" version={info.nodejs} />
|
||||
<ServerAboutItem id="libvips" title="Libvips" version={info.libvips} />
|
||||
<ServerAboutItem
|
||||
id="imagemagick"
|
||||
title="ImageMagick"
|
||||
version={info.imagemagick}
|
||||
class={(info.imagemagick?.length || 0) > 10 ? 'col-span-2' : ''}
|
||||
/>
|
||||
<ServerAboutItem
|
||||
id="ffmpeg"
|
||||
title="FFmpeg"
|
||||
version={info.ffmpeg}
|
||||
class={(info.ffmpeg?.length || 0) > 10 ? 'col-span-2' : ''}
|
||||
/>
|
||||
|
||||
{#if info.repository && info.repositoryUrl}
|
||||
<div>
|
||||
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="version-desc"
|
||||
>{$t('repository')}</label
|
||||
>
|
||||
<div>
|
||||
<a
|
||||
href={info.repositoryUrl}
|
||||
class="underline text-sm immich-form-label"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
id="version-desc"
|
||||
>
|
||||
{info.repository}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<ServerAboutItem
|
||||
id="repository"
|
||||
title={$t('repository')}
|
||||
version={info.repository}
|
||||
versionHref={info.repositoryUrl}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if info.sourceRef && info.sourceCommit && info.sourceUrl}
|
||||
<div>
|
||||
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="git-desc"
|
||||
>{$t('source')}</label
|
||||
>
|
||||
<div>
|
||||
<a
|
||||
href={info.sourceUrl}
|
||||
class="underline text-sm immich-form-label"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
id="git-desc"
|
||||
>
|
||||
{info.sourceRef}@{info.sourceCommit.slice(0, 9)}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<ServerAboutItem
|
||||
id="source"
|
||||
title={$t('source')}
|
||||
version="{info.sourceRef}@{info.sourceCommit.slice(0, 9)}"
|
||||
versionHref={info.sourceUrl}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if info.build && info.buildUrl}
|
||||
<div>
|
||||
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="build-desc"
|
||||
>{$t('build')}</label
|
||||
>
|
||||
<div>
|
||||
<a
|
||||
href={info.buildUrl}
|
||||
class="underline text-sm immich-form-label"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
id="build-desc"
|
||||
>
|
||||
{info.build}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<ServerAboutItem id="build" title={$t('build')} version={info.build} versionHref={info.buildUrl} />
|
||||
{/if}
|
||||
|
||||
{#if info.buildImage && info.buildImage}
|
||||
<div>
|
||||
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="build-image-desc"
|
||||
>{$t('build_image')}</label
|
||||
>
|
||||
<div>
|
||||
<a
|
||||
href={info.buildImageUrl}
|
||||
class="underline text-sm immich-form-label"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
id="build-image-desc"
|
||||
>
|
||||
{info.buildImage}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if info.sourceRef === 'main' && info.repository === 'immich-app/immich'}
|
||||
<div class="col-span-full p-4 flex gap-1">
|
||||
<Icon icon={mdiAlert} size="2em" color="#ffcc4d" />
|
||||
<p class="immich-form-label text-sm" id="main-warning">
|
||||
{$t('main_branch_warning')}
|
||||
</p>
|
||||
</div>
|
||||
<ServerAboutItem
|
||||
id="build-image"
|
||||
title={$t('build_image')}
|
||||
version={info.buildImage}
|
||||
versionHref={info.buildImageUrl}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="col-span-full">
|
||||
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="version-history"
|
||||
>{$t('version_history')}</label
|
||||
>
|
||||
<Label size="small" color="primary" for="version-history">{$t('version_history')}</Label>
|
||||
<ul id="version-history" class="list-none">
|
||||
{#each versions.slice(0, 5) as item (item.id)}
|
||||
{@const createdAt = DateTime.fromISO(item.createdAt)}
|
||||
|
@ -146,7 +146,7 @@
|
||||
{:else}
|
||||
<div class="text-sm">
|
||||
{$t('public_album')} |
|
||||
<span class="text-immich-primary dark:text-immich-dark-primary">{editingLink.album?.albumName}</span>
|
||||
<span class="text-primary">{editingLink.album?.albumName}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
@ -157,7 +157,7 @@
|
||||
{:else}
|
||||
<div class="text-sm">
|
||||
{$t('individual_share')} |
|
||||
<span class="text-immich-primary dark:text-immich-dark-primary">{editingLink.description || ''}</span>
|
||||
<span class="text-primary">{editingLink.description || ''}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
@ -75,7 +75,7 @@
|
||||
|
||||
<Modal size="small" title={$t('slideshow_settings')} onClose={() => onClose()}>
|
||||
<ModalBody>
|
||||
<div class="flex flex-col gap-4 text-immich-primary dark:text-immich-dark-primary">
|
||||
<div class="flex flex-col gap-4 text-primary">
|
||||
<SettingDropdown
|
||||
title={$t('direction')}
|
||||
options={Object.values(navigationOptions)}
|
||||
|
@ -38,7 +38,7 @@
|
||||
|
||||
<Modal size="small" title={$t('create_tag')} icon={mdiTag} {onClose}>
|
||||
<ModalBody>
|
||||
<div class="text-immich-primary dark:text-immich-dark-primary">
|
||||
<div class="text-primary">
|
||||
<p class="text-sm dark:text-immich-dark-fg">
|
||||
{$t('create_tag_description')}
|
||||
</p>
|
||||
|
@ -101,7 +101,7 @@
|
||||
|
||||
<p>
|
||||
{$t('admin.note_apply_storage_label_previous_assets')}
|
||||
<a href={AppRoute.ADMIN_JOBS} class="text-immich-primary dark:text-immich-dark-primary">
|
||||
<a href={AppRoute.ADMIN_JOBS} class="text-primary">
|
||||
{$t('admin.storage_template_migration_job')}
|
||||
</a>
|
||||
</p>
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user