feat(wallpapers): add image crop support and i18n strings
Adds per-image image-crop support with persistent settings. Rendering adapts to crop mode, zoom and position per image. Locales the UI strings and notifications; app name updated. - Cropping workflow with per-image crop state and rendering - Localizes strings and notifications for en and de - Introduces set_image_crop command and its permission schema
This commit is contained in:
+2
-2
@@ -1,10 +1,10 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#f8faf7" />
|
||||
<title>LockScreenWallpaper</title>
|
||||
<title>WallpaperFlow</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -23,6 +23,15 @@ class GalleryArgs { var offset: Int = 0; var limit: Int = 48 }
|
||||
@InvokeArg
|
||||
class DeleteImageArgs { lateinit var id: String }
|
||||
|
||||
@InvokeArg
|
||||
class ImageCropArgs {
|
||||
lateinit var id: String
|
||||
lateinit var mode: String
|
||||
var zoom: Double = 1.0
|
||||
var positionX: Double = 0.5
|
||||
var positionY: Double = 0.5
|
||||
}
|
||||
|
||||
@TauriPlugin
|
||||
class WallpaperPlugin(private val activity: Activity) : Plugin(activity) {
|
||||
private val io = Executors.newSingleThreadExecutor()
|
||||
@@ -54,6 +63,15 @@ class WallpaperPlugin(private val activity: Activity) : Plugin(activity) {
|
||||
} catch (error: Exception) { invoke.reject(error.message ?: "Bild konnte nicht gelöscht werden") }
|
||||
}
|
||||
|
||||
@Command fun setImageCrop(invoke: Invoke) = io.execute {
|
||||
try {
|
||||
val args = invoke.parseArgs(ImageCropArgs::class.java)
|
||||
val image = WallpaperStore.setCrop(activity, args.id, args.mode, args.zoom, args.positionX, args.positionY)
|
||||
?: throw IllegalArgumentException("Bild wurde nicht gefunden")
|
||||
invoke.resolve(image)
|
||||
} catch (error: Exception) { invoke.reject(error.message ?: "Bildausschnitt konnte nicht gespeichert werden") }
|
||||
}
|
||||
|
||||
@ActivityCallback
|
||||
fun selectedImages(invoke: Invoke, result: ActivityResult) {
|
||||
if (result.resultCode != Activity.RESULT_OK) { invoke.reject("Bildauswahl abgebrochen"); return }
|
||||
|
||||
@@ -29,8 +29,8 @@ class WallpaperRotationService : Service() {
|
||||
val pending = PendingIntent.getActivity(this, 0, launch, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
val notification = NotificationCompat.Builder(this, CHANNEL)
|
||||
.setSmallIcon(android.R.drawable.ic_menu_gallery)
|
||||
.setContentTitle("LockScreenWallpaper ist aktiv")
|
||||
.setContentText("Das Motiv wechselt beim Aktivieren des Displays.")
|
||||
.setContentTitle(getString(R.string.wallpaper_service_title))
|
||||
.setContentText(getString(R.string.wallpaper_service_text))
|
||||
.setOngoing(true).setSilent(true).setContentIntent(pending).build()
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
ContextCompat.registerReceiver(this, screenReceiver, IntentFilter(Intent.ACTION_SCREEN_ON), ContextCompat.RECEIVER_NOT_EXPORTED)
|
||||
@@ -42,7 +42,7 @@ class WallpaperRotationService : Service() {
|
||||
|
||||
private fun createChannel() {
|
||||
if (Build.VERSION.SDK_INT >= 26) {
|
||||
val channel = NotificationChannel(CHANNEL, "Automatischer Bildwechsel", NotificationManager.IMPORTANCE_LOW)
|
||||
val channel = NotificationChannel(CHANNEL, getString(R.string.wallpaper_channel_name), NotificationManager.IMPORTANCE_LOW)
|
||||
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,16 +4,23 @@ import android.app.WallpaperManager
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.Paint
|
||||
import android.util.Base64
|
||||
import app.tauri.plugin.JSArray
|
||||
import app.tauri.plugin.JSObject
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import kotlin.random.Random
|
||||
import org.json.JSONObject
|
||||
|
||||
object WallpaperStore {
|
||||
private const val PREFS = "wechselbild"
|
||||
private const val KEY_INDEX = "current_index"
|
||||
private const val CROP_PREFIX = "crop_"
|
||||
private data class CropSettings(val mode: String = "cover", val zoom: Double = 1.0, val x: Double = 0.5, val y: Double = 0.5)
|
||||
fun directory(context: Context) = File(context.filesDir, "wallpapers").apply { mkdirs() }
|
||||
fun files(context: Context) = directory(context).listFiles()?.filter { it.isFile }?.sortedBy { it.name } ?: emptyList()
|
||||
private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
@@ -51,11 +58,7 @@ object WallpaperStore {
|
||||
val safeLimit = limit.coerceIn(1, 100)
|
||||
val items = JSArray()
|
||||
originals.drop(safeOffset).take(safeLimit).forEachIndexed { pageIndex, file ->
|
||||
items.put(JSObject().apply {
|
||||
put("id", file.name)
|
||||
put("url", thumbnailDataUrl(file))
|
||||
put("selected", safeOffset + pageIndex == selectedIndex)
|
||||
})
|
||||
items.put(galleryImage(context, file, safeOffset + pageIndex == selectedIndex))
|
||||
}
|
||||
return JSObject().apply {
|
||||
put("total", originals.size)
|
||||
@@ -63,6 +66,55 @@ object WallpaperStore {
|
||||
}
|
||||
}
|
||||
|
||||
private fun cropKey(id: String) = CROP_PREFIX + id
|
||||
|
||||
private fun crop(context: Context, file: File): CropSettings {
|
||||
val raw = prefs(context).getString(cropKey(file.name), null) ?: return CropSettings()
|
||||
return try {
|
||||
val json = JSONObject(raw)
|
||||
CropSettings(
|
||||
mode = if (json.optString("mode") == "contain") "contain" else "cover",
|
||||
zoom = json.optDouble("zoom", 1.0).coerceIn(0.35, 3.0),
|
||||
x = json.optDouble("x", 0.5).coerceIn(0.0, 1.0),
|
||||
y = json.optDouble("y", 0.5).coerceIn(0.0, 1.0),
|
||||
)
|
||||
} catch (_: Exception) { CropSettings() }
|
||||
}
|
||||
|
||||
private fun galleryImage(context: Context, file: File, selected: Boolean): JSObject {
|
||||
val crop = crop(context, file)
|
||||
return JSObject().apply {
|
||||
put("id", file.name)
|
||||
put("url", thumbnailDataUrl(file))
|
||||
put("selected", selected)
|
||||
put("cropMode", crop.mode)
|
||||
put("cropZoom", crop.zoom)
|
||||
put("cropPositionX", crop.x)
|
||||
put("cropPositionY", crop.y)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun setCrop(context: Context, id: String, mode: String, zoom: Double, x: Double, y: Double): JSObject? {
|
||||
if (id.isBlank() || File(id).name != id) return null
|
||||
val file = files(context).firstOrNull { it.name == id } ?: return null
|
||||
val normalized = CropSettings(
|
||||
mode = if (mode == "contain") "contain" else "cover",
|
||||
zoom = zoom.coerceIn(0.35, 3.0),
|
||||
x = x.coerceIn(0.0, 1.0),
|
||||
y = y.coerceIn(0.0, 1.0),
|
||||
)
|
||||
val json = JSONObject().apply {
|
||||
put("mode", normalized.mode)
|
||||
put("zoom", normalized.zoom)
|
||||
put("x", normalized.x)
|
||||
put("y", normalized.y)
|
||||
}
|
||||
prefs(context).edit().putString(cropKey(id), json.toString()).apply()
|
||||
val selectedIndex = prefs(context).getInt(KEY_INDEX, 0)
|
||||
return galleryImage(context, file, files(context).indexOf(file) == selectedIndex)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun delete(context: Context, id: String): Boolean {
|
||||
if (id.isBlank() || File(id).name != id) return false
|
||||
@@ -79,10 +131,48 @@ object WallpaperStore {
|
||||
previousIndex >= remaining -> remaining - 1
|
||||
else -> previousIndex
|
||||
}
|
||||
preferences.edit().putInt(KEY_INDEX, nextIndex).apply()
|
||||
preferences.edit().remove(cropKey(id)).putInt(KEY_INDEX, nextIndex).apply()
|
||||
return true
|
||||
}
|
||||
|
||||
private fun renderForScreen(context: Context, source: Bitmap, crop: CropSettings): Bitmap {
|
||||
val metrics = context.resources.displayMetrics
|
||||
val targetWidth = metrics.widthPixels.coerceAtLeast(1)
|
||||
val targetHeight = metrics.heightPixels.coerceAtLeast(1)
|
||||
val widthScale = targetWidth.toDouble() / source.width
|
||||
val heightScale = targetHeight.toDouble() / source.height
|
||||
val baseScale = if (crop.mode == "contain") minOf(widthScale, heightScale) else maxOf(widthScale, heightScale)
|
||||
val scale = (baseScale * crop.zoom).toFloat()
|
||||
val scaledWidth = source.width * scale
|
||||
val scaledHeight = source.height * scale
|
||||
val left = ((targetWidth - scaledWidth) * crop.x).toFloat()
|
||||
val top = ((targetHeight - scaledHeight) * crop.y).toFloat()
|
||||
|
||||
return Bitmap.createBitmap(targetWidth, targetHeight, Bitmap.Config.ARGB_8888).also { output ->
|
||||
val canvas = Canvas(output)
|
||||
canvas.drawColor(Color.BLACK)
|
||||
val matrix = Matrix().apply {
|
||||
setScale(scale, scale)
|
||||
postTranslate(left, top)
|
||||
}
|
||||
canvas.drawBitmap(source, matrix, Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG))
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeForScreen(context: Context, file: File): Bitmap? {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeFile(file.absolutePath, bounds)
|
||||
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
||||
val metrics = context.resources.displayMetrics
|
||||
val targetLongSide = maxOf(metrics.widthPixels, metrics.heightPixels).coerceAtLeast(1)
|
||||
var sample = 1
|
||||
while (maxOf(bounds.outWidth, bounds.outHeight) / (sample * 2) >= targetLongSide) sample *= 2
|
||||
return BitmapFactory.decodeFile(file.absolutePath, BitmapFactory.Options().apply {
|
||||
inSampleSize = sample
|
||||
inPreferredConfig = Bitmap.Config.ARGB_8888
|
||||
})
|
||||
}
|
||||
|
||||
private fun thumbnailDataUrl(file: File): String {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeFile(file.absolutePath, bounds)
|
||||
@@ -105,13 +195,17 @@ object WallpaperStore {
|
||||
val index = if (shuffle(context) && images.size > 1) {
|
||||
generateSequence { Random.nextInt(images.size) }.first { it != previous }
|
||||
} else (previous + 1).mod(images.size)
|
||||
val bitmap = BitmapFactory.decodeFile(images[index].absolutePath) ?: return false
|
||||
val bitmap = decodeForScreen(context, images[index]) ?: return false
|
||||
val rendered = renderForScreen(context, bitmap, crop(context, images[index]))
|
||||
try {
|
||||
val manager = WallpaperManager.getInstance(context)
|
||||
if (android.os.Build.VERSION.SDK_INT >= 24 && lockOnly(context)) manager.setBitmap(bitmap, null, true, WallpaperManager.FLAG_LOCK)
|
||||
else manager.setBitmap(bitmap)
|
||||
if (android.os.Build.VERSION.SDK_INT >= 24 && lockOnly(context)) manager.setBitmap(rendered, null, true, WallpaperManager.FLAG_LOCK)
|
||||
else manager.setBitmap(rendered)
|
||||
preferences.edit().putInt(KEY_INDEX, index).apply()
|
||||
return true
|
||||
} finally { bitmap.recycle() }
|
||||
} finally {
|
||||
rendered.recycle()
|
||||
bitmap.recycle()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<resources>
|
||||
<string name="wallpaper_service_title">WallpaperFlow ist aktiv</string>
|
||||
<string name="wallpaper_service_text">Das Motiv wechselt beim Aktivieren des Displays.</string>
|
||||
<string name="wallpaper_channel_name">Automatischer Bildwechsel</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,5 @@
|
||||
<resources>
|
||||
<string name="wallpaper_service_title">WallpaperFlow is active</string>
|
||||
<string name="wallpaper_service_text">The wallpaper changes whenever the screen wakes.</string>
|
||||
<string name="wallpaper_channel_name">Automatic wallpaper rotation</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,13 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
"$schema" = "../../schemas/schema.json"
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-set-image-crop"
|
||||
description = "Enables the set_image_crop command without any pre-configured scope."
|
||||
commands.allow = ["set_image_crop"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-set-image-crop"
|
||||
description = "Denies the set_image_crop command without any pre-configured scope."
|
||||
commands.deny = ["set_image_crop"]
|
||||
@@ -8,6 +8,7 @@ Allow the LockScreenWallpaper app to manage its wallpaper collection
|
||||
- `allow-get-gallery`
|
||||
- `allow-select-images`
|
||||
- `allow-delete-image`
|
||||
- `allow-set-image-crop`
|
||||
- `allow-set-setting`
|
||||
- `allow-next-wallpaper`
|
||||
|
||||
@@ -153,6 +154,32 @@ Denies the select_images command without any pre-configured scope.
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`wallpaper:allow-set-image-crop`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the set_image_crop command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`wallpaper:deny-set-image-crop`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the set_image_crop command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`wallpaper:allow-set-setting`
|
||||
|
||||
</td>
|
||||
|
||||
@@ -354,6 +354,18 @@
|
||||
"const": "deny-select-images",
|
||||
"markdownDescription": "Denies the select_images command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the set_image_crop command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-set-image-crop",
|
||||
"markdownDescription": "Enables the set_image_crop command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the set_image_crop command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-set-image-crop",
|
||||
"markdownDescription": "Denies the set_image_crop command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the set_setting command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -367,10 +379,10 @@
|
||||
"markdownDescription": "Denies the set_setting command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-set-setting`\n- `allow-next-wallpaper`",
|
||||
"description": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-set-image-crop`\n- `allow-set-setting`\n- `allow-next-wallpaper`",
|
||||
"type": "string",
|
||||
"const": "default",
|
||||
"markdownDescription": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-set-setting`\n- `allow-next-wallpaper`"
|
||||
"markdownDescription": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-set-image-crop`\n- `allow-set-setting`\n- `allow-next-wallpaper`"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="116" fill="#145d32"/>
|
||||
<path d="M142 128h228a42 42 0 0 1 42 42v210a42 42 0 0 1-42 42H142a42 42 0 0 1-42-42V170a42 42 0 0 1 42-42Z" fill="#f8faf7" opacity=".22"/>
|
||||
<path d="M172 92h190a38 38 0 0 1 38 38v214a38 38 0 0 1-38 38H172a38 38 0 0 1-38-38V130a38 38 0 0 1 38-38Z" fill="#f8faf7"/>
|
||||
<circle cx="321" cy="170" r="31" fill="#a9cda7"/>
|
||||
<path d="m157 319 72-91 53 62 37-42 65 79v17a17 17 0 0 1-17 17H168a17 17 0 0 1-17-17v-15Z" fill="#145d32"/>
|
||||
<path d="M366 112a122 122 0 0 1 39 61" fill="none" stroke="#a9cda7" stroke-width="18" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 671 B |
@@ -0,0 +1,4 @@
|
||||
<resources>
|
||||
<string name="app_name">WallpaperFlow</string>
|
||||
<string name="main_activity_title">WallpaperFlow</string>
|
||||
</resources>
|
||||
@@ -1,4 +1,4 @@
|
||||
<resources>
|
||||
<string name="app_name">"LockScreenWallpaper"</string>
|
||||
<string name="main_activity_title">"LockScreenWallpaper"</string>
|
||||
<string name="app_name">"WallpaperFlow"</string>
|
||||
<string name="main_activity_title">"WallpaperFlow"</string>
|
||||
</resources>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2193,10 +2193,10 @@
|
||||
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-set-setting`\n- `allow-next-wallpaper`",
|
||||
"description": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-set-image-crop`\n- `allow-set-setting`\n- `allow-next-wallpaper`",
|
||||
"type": "string",
|
||||
"const": "wallpaper:default",
|
||||
"markdownDescription": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-set-setting`\n- `allow-next-wallpaper`"
|
||||
"markdownDescription": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-set-image-crop`\n- `allow-set-setting`\n- `allow-next-wallpaper`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the delete_image command without any pre-configured scope.",
|
||||
@@ -2228,6 +2228,12 @@
|
||||
"const": "wallpaper:allow-select-images",
|
||||
"markdownDescription": "Enables the select_images command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the set_image_crop command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "wallpaper:allow-set-image-crop",
|
||||
"markdownDescription": "Enables the set_image_crop command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the set_setting command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -2264,6 +2270,12 @@
|
||||
"const": "wallpaper:deny-select-images",
|
||||
"markdownDescription": "Denies the select_images command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the set_image_crop command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "wallpaper:deny-set-image-crop",
|
||||
"markdownDescription": "Denies the set_image_crop command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the set_setting command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
|
||||
@@ -2193,10 +2193,10 @@
|
||||
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-set-setting`\n- `allow-next-wallpaper`",
|
||||
"description": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-set-image-crop`\n- `allow-set-setting`\n- `allow-next-wallpaper`",
|
||||
"type": "string",
|
||||
"const": "wallpaper:default",
|
||||
"markdownDescription": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-set-setting`\n- `allow-next-wallpaper`"
|
||||
"markdownDescription": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-set-image-crop`\n- `allow-set-setting`\n- `allow-next-wallpaper`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the delete_image command without any pre-configured scope.",
|
||||
@@ -2228,6 +2228,12 @@
|
||||
"const": "wallpaper:allow-select-images",
|
||||
"markdownDescription": "Enables the select_images command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the set_image_crop command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "wallpaper:allow-set-image-crop",
|
||||
"markdownDescription": "Enables the set_image_crop command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the set_setting command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -2264,6 +2270,12 @@
|
||||
"const": "wallpaper:deny-select-images",
|
||||
"markdownDescription": "Denies the select_images command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the set_image_crop command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "wallpaper:deny-set-image-crop",
|
||||
"markdownDescription": "Denies the set_image_crop command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the set_setting command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
|
||||
@@ -2193,10 +2193,10 @@
|
||||
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-set-setting`\n- `allow-next-wallpaper`",
|
||||
"description": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-set-image-crop`\n- `allow-set-setting`\n- `allow-next-wallpaper`",
|
||||
"type": "string",
|
||||
"const": "wallpaper:default",
|
||||
"markdownDescription": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-set-setting`\n- `allow-next-wallpaper`"
|
||||
"markdownDescription": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-set-image-crop`\n- `allow-set-setting`\n- `allow-next-wallpaper`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the delete_image command without any pre-configured scope.",
|
||||
@@ -2228,6 +2228,12 @@
|
||||
"const": "wallpaper:allow-select-images",
|
||||
"markdownDescription": "Enables the select_images command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the set_image_crop command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "wallpaper:allow-set-image-crop",
|
||||
"markdownDescription": "Enables the set_image_crop command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the set_setting command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -2264,6 +2270,12 @@
|
||||
"const": "wallpaper:deny-select-images",
|
||||
"markdownDescription": "Denies the select_images command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the set_image_crop command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "wallpaper:deny-set-image-crop",
|
||||
"markdownDescription": "Denies the set_image_crop command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the set_setting command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
|
||||
@@ -2193,10 +2193,10 @@
|
||||
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-set-setting`\n- `allow-next-wallpaper`",
|
||||
"description": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-set-image-crop`\n- `allow-set-setting`\n- `allow-next-wallpaper`",
|
||||
"type": "string",
|
||||
"const": "wallpaper:default",
|
||||
"markdownDescription": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-set-setting`\n- `allow-next-wallpaper`"
|
||||
"markdownDescription": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-set-image-crop`\n- `allow-set-setting`\n- `allow-next-wallpaper`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the delete_image command without any pre-configured scope.",
|
||||
@@ -2228,6 +2228,12 @@
|
||||
"const": "wallpaper:allow-select-images",
|
||||
"markdownDescription": "Enables the select_images command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the set_image_crop command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "wallpaper:allow-set-image-crop",
|
||||
"markdownDescription": "Enables the set_image_crop command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the set_setting command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -2264,6 +2270,12 @@
|
||||
"const": "wallpaper:deny-select-images",
|
||||
"markdownDescription": "Denies the select_images command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the set_image_crop command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "wallpaper:deny-set-image-crop",
|
||||
"markdownDescription": "Denies the set_image_crop command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the set_setting command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "LockScreenWallpaper",
|
||||
"productName": "WallpaperFlow",
|
||||
"version": "0.1.0",
|
||||
"identifier": "de.wechselbild.app",
|
||||
"build": { "beforeDevCommand": "npm run dev", "devUrl": "http://localhost:1420", "beforeBuildCommand": "npm run build", "frontendDist": "../dist" },
|
||||
"app": { "windows": [{ "title": "LockScreenWallpaper", "width": 420, "height": 860, "resizable": true }], "security": { "csp": null } },
|
||||
"bundle": { "active": true, "targets": "all", "icon": [] }
|
||||
"app": { "windows": [{ "title": "WallpaperFlow", "width": 420, "height": 860, "resizable": true }], "security": { "csp": null } },
|
||||
"bundle": { "active": true, "targets": "all", "icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico", "icons/icon.png"] }
|
||||
}
|
||||
|
||||
+130
-29
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ArrowLeft, Check, ChevronRight, Home, Images, LockKeyhole, Plus, Settings, Shuffle, Smartphone, Sparkles, Trash2 } from "lucide-react";
|
||||
import { deleteImage, getGallery, getState, nextWallpaper, selectImages, setSetting, type GalleryImage, type WallpaperState } from "./native";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ArrowLeft, Check, ChevronRight, Crop, Home, Images, Languages, LockKeyhole, Plus, RotateCcw, Save, Settings, Shuffle, Smartphone, Sparkles, Trash2 } from "lucide-react";
|
||||
import { deleteImage, getGallery, getState, nextWallpaper, selectImages, setImageCrop, setSetting, type GalleryImage, type WallpaperState } from "./native";
|
||||
import { initialLanguage, translations, type Language } from "./i18n";
|
||||
|
||||
const initial: WallpaperState = { imageCount: 0, enabled: false, shuffle: true, lockScreenOnly: true, currentIndex: 0, imageUrls: [] };
|
||||
|
||||
@@ -14,15 +15,31 @@ function SettingRow({ icon, label, value, onChange }: { icon: React.ReactNode; l
|
||||
|
||||
export default function App() {
|
||||
const [state, setState] = useState(initial);
|
||||
const [tab, setTab] = useState<"home" | "settings" | "gallery">("home");
|
||||
const [tab, setTab] = useState<"home" | "settings" | "gallery" | "editor">("home");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [notice, setNotice] = useState("");
|
||||
const [language, setLanguage] = useState<Language>(initialLanguage);
|
||||
const [gallery, setGallery] = useState<GalleryImage[]>([]);
|
||||
const [galleryTotal, setGalleryTotal] = useState(0);
|
||||
const [galleryLoading, setGalleryLoading] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState("");
|
||||
const [editing, setEditing] = useState<GalleryImage | null>(null);
|
||||
const phonePreviewRef = useRef<HTMLDivElement>(null);
|
||||
const previewPointers = useRef(new Map<number, { x: number; y: number }>());
|
||||
const previewGesture = useRef<null | { centerX: number; centerY: number; distance: number; x: number; y: number; zoom: number }>(null);
|
||||
|
||||
useEffect(() => { getState().then(setState).catch(() => setState(initial)); }, []);
|
||||
useEffect(() => {
|
||||
if (!notice) return;
|
||||
const timeout = window.setTimeout(() => setNotice(""), 3000);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [notice]);
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem("wallpaperflow-language", language);
|
||||
document.documentElement.lang = language;
|
||||
document.title = "WallpaperFlow";
|
||||
}, [language]);
|
||||
const t = translations[language];
|
||||
const current = state.imageUrls[state.currentIndex] ?? "/wallpapers/alpine.png";
|
||||
const photos = useMemo(() => state.imageUrls, [state.imageUrls]);
|
||||
|
||||
@@ -32,13 +49,13 @@ export default function App() {
|
||||
const page = await getGallery(offset, 48);
|
||||
setGallery(previous => append ? [...previous, ...page.items] : page.items);
|
||||
setGalleryTotal(page.total);
|
||||
} catch { setNotice("Galerie konnte nicht geladen werden"); }
|
||||
} catch { setNotice(t.galleryLoadFailed); }
|
||||
finally { setGalleryLoading(false); }
|
||||
}
|
||||
|
||||
async function update(name: "enabled" | "shuffle" | "lockScreenOnly", value: boolean) {
|
||||
setState(prev => ({ ...prev, [name]: value }));
|
||||
try { setState(await setSetting(name, value)); setNotice(name === "enabled" ? (value ? "Automatischer Wechsel ist aktiv" : "Automatischer Wechsel pausiert") : "Einstellung gespeichert"); } catch { setNotice("Diese Funktion ist auf Android verfügbar"); }
|
||||
try { setState(await setSetting(name, value)); setNotice(name === "enabled" ? (value ? t.automaticEnabled : t.automaticDisabled) : t.settingSaved); } catch { setNotice(t.androidOnly); }
|
||||
}
|
||||
|
||||
async function choose() {
|
||||
@@ -46,61 +63,145 @@ export default function App() {
|
||||
try {
|
||||
setState(await selectImages());
|
||||
if (tab === "gallery") await loadGallery();
|
||||
setNotice("Bilder wurden zur Sammlung hinzugefügt");
|
||||
} catch (error) { setNotice(String(error).includes("cancel") ? "Auswahl abgebrochen" : "Bildauswahl ist auf Android verfügbar"); }
|
||||
setNotice(t.imagesAdded);
|
||||
} catch (error) { setNotice(String(error).includes("cancel") ? t.selectionCancelled : t.imageSelectionAndroid); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function remove(image: GalleryImage) {
|
||||
if (!window.confirm("Möchtest du dieses Bild wirklich aus deiner Sammlung löschen?")) return;
|
||||
if (!window.confirm(t.deleteConfirm)) return;
|
||||
setDeletingId(image.id);
|
||||
try {
|
||||
setState(await deleteImage(image.id));
|
||||
setGallery(previous => previous.filter(item => item.id !== image.id));
|
||||
setGalleryTotal(previous => Math.max(0, previous - 1));
|
||||
setNotice("Bild wurde gelöscht");
|
||||
} catch { setNotice("Bild konnte nicht gelöscht werden"); }
|
||||
setNotice(t.imageDeleted);
|
||||
} catch { setNotice(t.imageDeleteFailed); }
|
||||
finally { setDeletingId(""); }
|
||||
}
|
||||
|
||||
function openEditor(image: GalleryImage) {
|
||||
setEditing({ ...image });
|
||||
setTab("editor");
|
||||
}
|
||||
|
||||
async function saveCrop() {
|
||||
if (!editing) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const saved = await setImageCrop(editing);
|
||||
setGallery(previous => previous.map(image => image.id === saved.id ? saved : image));
|
||||
setEditing(saved);
|
||||
setNotice(t.cropSaved);
|
||||
setTab("gallery");
|
||||
} catch { setNotice(t.cropSaveFailed); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
function gestureGeometry() {
|
||||
const points = [...previewPointers.current.values()];
|
||||
if (!points.length) return { centerX: 0, centerY: 0, distance: 0 };
|
||||
if (points.length === 1) return { centerX: points[0].x, centerY: points[0].y, distance: 0 };
|
||||
const [first, second] = points;
|
||||
return {
|
||||
centerX: (first.x + second.x) / 2,
|
||||
centerY: (first.y + second.y) / 2,
|
||||
distance: Math.hypot(second.x - first.x, second.y - first.y),
|
||||
};
|
||||
}
|
||||
|
||||
function beginPreviewGesture(event: React.PointerEvent) {
|
||||
if (!editing) return;
|
||||
event.preventDefault();
|
||||
phonePreviewRef.current?.setPointerCapture(event.pointerId);
|
||||
previewPointers.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||
const geometry = gestureGeometry();
|
||||
previewGesture.current = { ...geometry, x: editing.cropPositionX, y: editing.cropPositionY, zoom: editing.cropZoom };
|
||||
}
|
||||
|
||||
function movePreviewGesture(event: React.PointerEvent) {
|
||||
if (!previewPointers.current.has(event.pointerId)) return;
|
||||
event.preventDefault();
|
||||
previewPointers.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||
const gesture = previewGesture.current;
|
||||
const preview = phonePreviewRef.current;
|
||||
if (!gesture || !preview) return;
|
||||
const bounds = preview.getBoundingClientRect();
|
||||
const geometry = gestureGeometry();
|
||||
const zoom = geometry.distance && gesture.distance
|
||||
? Math.min(3, Math.max(0.35, gesture.zoom * geometry.distance / gesture.distance))
|
||||
: gesture.zoom;
|
||||
const x = gesture.x - (geometry.centerX - gesture.centerX) / Math.max(1, bounds.width * zoom);
|
||||
const y = gesture.y - (geometry.centerY - gesture.centerY) / Math.max(1, bounds.height * zoom);
|
||||
setEditing(previous => previous ? {
|
||||
...previous,
|
||||
cropZoom: zoom,
|
||||
cropPositionX: Math.min(1, Math.max(0, x)),
|
||||
cropPositionY: Math.min(1, Math.max(0, y)),
|
||||
} : previous);
|
||||
}
|
||||
|
||||
function endPreviewGesture(event: React.PointerEvent) {
|
||||
previewPointers.current.delete(event.pointerId);
|
||||
if (phonePreviewRef.current?.hasPointerCapture(event.pointerId)) phonePreviewRef.current.releasePointerCapture(event.pointerId);
|
||||
if (!editing || !previewPointers.current.size) {
|
||||
previewGesture.current = null;
|
||||
return;
|
||||
}
|
||||
const geometry = gestureGeometry();
|
||||
previewGesture.current = { ...geometry, x: editing.cropPositionX, y: editing.cropPositionY, zoom: editing.cropZoom };
|
||||
}
|
||||
|
||||
async function next() {
|
||||
setBusy(true);
|
||||
try { setState(await nextWallpaper()); setNotice("Sperrbildschirm wurde aktualisiert"); } catch { setState(prev => ({ ...prev, currentIndex: (prev.currentIndex + 1) % Math.max(1, photos.length) })); }
|
||||
try { setState(await nextWallpaper()); setNotice(t.wallpaperUpdated); } catch { setState(prev => ({ ...prev, currentIndex: (prev.currentIndex + 1) % Math.max(1, photos.length) })); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
return <main className="app-shell">
|
||||
{tab === "gallery" ? <header className="gallery-header"><button className="icon-button" aria-label="Zurück" onClick={() => setTab("home")}><ArrowLeft /></button><div><h1>Meine Sammlung</h1><p>{galleryTotal} {galleryTotal === 1 ? "Bild" : "Bilder"}</p></div><button className="icon-button" aria-label="Bilder hinzufügen" onClick={choose} disabled={busy}><Plus /></button></header> :
|
||||
<header><div><h1>LockScreenWallpaper</h1><p>{state.enabled ? "Deine Motive wechseln automatisch" : "Automatischer Wechsel ist pausiert"}</p></div><button className="icon-button" aria-label="Einstellungen" onClick={() => setTab("settings")}><Settings /></button></header>}
|
||||
{tab === "gallery" ? <header className="gallery-header"><button className="icon-button" aria-label={t.back} onClick={() => setTab("home")}><ArrowLeft /></button><div><h1>{t.collection}</h1><p>{galleryTotal} {galleryTotal === 1 ? t.image : t.images}</p></div><button className="icon-button" aria-label={t.addImages} onClick={choose} disabled={busy}><Plus /></button></header> : tab === "editor" ?
|
||||
<header className="gallery-header"><button className="icon-button" aria-label={t.backToGallery} onClick={() => setTab("gallery")}><ArrowLeft /></button><div><h1>{t.editImage}</h1><p>{t.savedForImage}</p></div><button className="icon-button save-crop" aria-label={t.saveCrop} onClick={saveCrop} disabled={busy}><Save /></button></header> :
|
||||
<header className="app-header"><div className="brand"><img className="brand-logo" src="/app-icon.svg" alt="" /><div className="brand-copy"><h1>WallpaperFlow</h1><p>{state.enabled ? t.automaticActive : t.automaticPaused}</p></div></div><button className={`icon-button settings-shortcut ${tab === "settings" ? "selected" : ""}`} aria-label={t.settings} aria-current={tab === "settings" ? "page" : undefined} onClick={() => setTab("settings")}><Settings /></button></header>}
|
||||
|
||||
<div className="content">
|
||||
{tab === "home" ? <>
|
||||
<section className="hero" aria-label="Aktuelles Hintergrundbild">
|
||||
<section className="hero" aria-label={t.currentWallpaper}>
|
||||
<div className="photo-stack" />
|
||||
<img src={current} alt="Aktuelles Motiv" />
|
||||
<img src={current} alt={t.currentImage} />
|
||||
<div className="hero-shade" />
|
||||
<div className="hero-meta"><span><Sparkles size={16} /> Aktuelles Motiv</span><button onClick={next} disabled={busy}>Nächstes Motiv <ChevronRight size={18} /></button></div>
|
||||
<div className="hero-meta"><span><Sparkles size={16} /> {t.currentImage}</span><button onClick={next} disabled={busy}>{t.nextImage} <ChevronRight size={18} /></button></div>
|
||||
</section>
|
||||
|
||||
<SettingRow icon={<Smartphone />} label="Bei jedem Aktivieren wechseln" value={state.enabled} onChange={v => update("enabled", v)} />
|
||||
<SettingRow icon={<Smartphone />} label={t.changeOnWake} value={state.enabled} onChange={v => update("enabled", v)} />
|
||||
|
||||
<button className="primary" onClick={choose} disabled={busy}><Images />{busy ? "Bitte warten …" : "Bilder auswählen"}</button>
|
||||
<button className="primary" onClick={choose} disabled={busy}><Images />{busy ? t.pleaseWait : t.selectImages}</button>
|
||||
|
||||
<section className="collection"><div className="section-heading"><h2>Meine Sammlung</h2><button onClick={() => { setTab("gallery"); void loadGallery(); }}>{state.imageCount} {state.imageCount === 1 ? "Bild" : "Bilder"} <ChevronRight /></button></div>
|
||||
{photos.length ? <div className="photo-rail">{photos.map((photo, index) => <button key={`${photo}-${index}`} className={index === state.currentIndex ? "selected" : ""} onClick={() => setState(prev => ({ ...prev, currentIndex: index }))}><img src={photo} alt={`Motiv ${index + 1}`} />{index === state.currentIndex && <span><Check /></span>}</button>)}</div> : <button className="empty-collection" onClick={choose}><Images /><span>Noch keine Bilder ausgewählt</span></button>}
|
||||
<p className="hint">Tippe auf die Bildanzahl, um deine Galerie zu öffnen.</p>
|
||||
<section className="collection"><div className="section-heading"><h2>{t.collection}</h2><button onClick={() => { setTab("gallery"); void loadGallery(); }}>{state.imageCount} {state.imageCount === 1 ? t.image : t.images} <ChevronRight /></button></div>
|
||||
{photos.length ? <div className="photo-rail">{photos.map((photo, index) => <button key={`${photo}-${index}`} className={index === state.currentIndex ? "selected" : ""} onClick={() => setState(prev => ({ ...prev, currentIndex: index }))}><img src={photo} alt={`${t.motif} ${index + 1}`} />{index === state.currentIndex && <span><Check /></span>}</button>)}</div> : <button className="empty-collection" onClick={choose}><Images /><span>{t.noImages}</span></button>}
|
||||
<p className="hint">{t.collectionHint}</p>
|
||||
</section>
|
||||
|
||||
<section className="settings-list"><SettingRow icon={<Shuffle />} label="Zufällige Reihenfolge" value={state.shuffle} onChange={v => update("shuffle", v)} /><SettingRow icon={<LockKeyhole />} label="Nur Sperrbildschirm" value={state.lockScreenOnly} onChange={v => update("lockScreenOnly", v)} /></section>
|
||||
</> : tab === "settings" ? <section className="settings-page"><h2>Einstellungen</h2><p>Lege fest, wie LockScreenWallpaper im Hintergrund arbeitet.</p><div className="settings-list"><SettingRow icon={<Smartphone />} label="Bei jedem Aktivieren wechseln" value={state.enabled} onChange={v => update("enabled", v)} /><SettingRow icon={<Shuffle />} label="Zufällige Reihenfolge" value={state.shuffle} onChange={v => update("shuffle", v)} /><SettingRow icon={<LockKeyhole />} label="Nur Sperrbildschirm" value={state.lockScreenOnly} onChange={v => update("lockScreenOnly", v)} /></div><div className="info"><h3>Ohne Bilderlimit</h3><p>Deine Auswahl wird privat auf dem Gerät gespeichert. Die einzige Grenze ist der freie Speicherplatz.</p></div></section> :
|
||||
<section className="gallery-page" aria-label="Meine Bilder">
|
||||
{gallery.length ? <div className="gallery-grid">{gallery.map((image, index) => <article className={image.selected ? "current" : ""} key={image.id}><img src={image.url} alt={`Bild ${index + 1}`} />{image.selected && <span className="current-badge"><Check /> Aktuell</span>}<button className="delete-button" aria-label={`Bild ${index + 1} löschen`} onClick={() => remove(image)} disabled={deletingId === image.id}><Trash2 /></button></article>)}</div> : !galleryLoading && <div className="gallery-empty"><Images /><h2>Deine Sammlung ist leer</h2><p>Füge Bilder hinzu, die automatisch als Sperrbildschirm wechseln sollen.</p><button className="primary" onClick={choose}><Plus /> Bilder hinzufügen</button></div>}
|
||||
{galleryLoading && <p className="gallery-status">Galerie wird geladen …</p>}
|
||||
{!galleryLoading && gallery.length < galleryTotal && <button className="load-more" onClick={() => loadGallery(gallery.length, true)}>Weitere Bilder laden</button>}
|
||||
<section className="settings-list"><SettingRow icon={<Shuffle />} label={t.shuffle} value={state.shuffle} onChange={v => update("shuffle", v)} /><SettingRow icon={<LockKeyhole />} label={t.lockScreenOnly} value={state.lockScreenOnly} onChange={v => update("lockScreenOnly", v)} /></section>
|
||||
</> : tab === "settings" ? <section className="settings-page"><h2>{t.settings}</h2><p>{t.settingsIntro}</p><div className="language-setting"><div className="setting-icon"><Languages /></div><div><strong>{t.language}</strong><span>{language === "de" ? t.german : t.english}</span></div><div className="language-toggle" role="group" aria-label={t.language}><button className={language === "de" ? "active" : ""} onClick={() => setLanguage("de")}>DE</button><button className={language === "en" ? "active" : ""} onClick={() => setLanguage("en")}>EN</button></div></div><div className="settings-list"><SettingRow icon={<Smartphone />} label={t.changeOnWake} value={state.enabled} onChange={v => update("enabled", v)} /><SettingRow icon={<Shuffle />} label={t.shuffle} value={state.shuffle} onChange={v => update("shuffle", v)} /><SettingRow icon={<LockKeyhole />} label={t.lockScreenOnly} value={state.lockScreenOnly} onChange={v => update("lockScreenOnly", v)} /></div><div className="info"><h3>{t.noImageLimit}</h3><p>{t.privacyInfo}</p></div></section> : tab === "gallery" ?
|
||||
<section className="gallery-page" aria-label={t.myImages}>
|
||||
{gallery.length ? <div className="gallery-grid">{gallery.map((image, index) => <article className={image.selected ? "current" : ""} key={image.id}><img src={image.url} alt={`${t.image} ${index + 1}`} />{image.selected && <span className="current-badge"><Check /> {t.current}</span>}<button className="edit-button" aria-label={`${t.adjustImage} ${index + 1}`} onClick={() => openEditor(image)}><Crop /></button><button className="delete-button" aria-label={`${t.deleteImage} ${index + 1}`} onClick={() => remove(image)} disabled={deletingId === image.id}><Trash2 /></button></article>)}</div> : !galleryLoading && <div className="gallery-empty"><Images /><h2>{t.emptyCollection}</h2><p>{t.emptyCollectionText}</p><button className="primary" onClick={choose}><Plus /> {t.addImages}</button></div>}
|
||||
{galleryLoading && <p className="gallery-status">{t.galleryLoading}</p>}
|
||||
{!galleryLoading && gallery.length < galleryTotal && <button className="load-more" onClick={() => loadGallery(gallery.length, true)}>{t.loadMore}</button>}
|
||||
</section> : editing && <section className="crop-editor" aria-label={t.editCrop}>
|
||||
<div ref={phonePreviewRef} className="phone-preview interactive" aria-label={t.gestureLabel} onPointerDown={beginPreviewGesture} onPointerMove={movePreviewGesture} onPointerUp={endPreviewGesture} onPointerCancel={endPreviewGesture}>
|
||||
<img src={editing.url} alt={t.cropPreview} draggable={false} style={{ objectFit: editing.cropMode, objectPosition: `${editing.cropPositionX * 100}% ${editing.cropPositionY * 100}%`, transform: `scale(${editing.cropZoom})`, transformOrigin: `${editing.cropPositionX * 100}% ${editing.cropPositionY * 100}%` }} />
|
||||
<div className="preview-clock">12:34<span>{t.previewDate}</span></div>
|
||||
</div>
|
||||
<div className="crop-controls">
|
||||
<div className="fit-toggle" aria-label={t.imageFit}><button className={editing.cropMode === "cover" ? "active" : ""} onClick={() => setEditing({ ...editing, cropMode: "cover" })}>{t.fill}</button><button className={editing.cropMode === "contain" ? "active" : ""} onClick={() => setEditing({ ...editing, cropMode: "contain" })}>{t.fit}</button></div>
|
||||
<div className="direct-crop-heading"><span><Crop /> {t.adjustOnScreen}</span><strong>{editing.cropZoom.toFixed(2)}×</strong></div>
|
||||
<p className="direct-crop-help">{t.gestureHelp}</p>
|
||||
<button className="reset-crop" onClick={() => setEditing({ ...editing, cropMode: "cover", cropZoom: 1, cropPositionX: 0.5, cropPositionY: 0.5 })}><RotateCcw /> {t.reset}</button>
|
||||
</div>
|
||||
</section>}
|
||||
</div>
|
||||
|
||||
{notice && <button className="snackbar" onClick={() => setNotice("")}>{notice}</button>}
|
||||
{tab !== "gallery" && <nav><button className={tab === "home" ? "active" : ""} onClick={() => setTab("home")}><Home /><span>Start</span></button><button className={tab === "settings" ? "active" : ""} onClick={() => setTab("settings")}><Settings /><span>Einstellungen</span></button></nav>}
|
||||
{tab !== "gallery" && tab !== "editor" && <nav><button className={tab === "home" ? "active" : ""} onClick={() => setTab("home")}><Home /><span>{t.home}</span></button><button className={tab === "settings" ? "active" : ""} onClick={() => setTab("settings")}><Settings /><span>{t.settings}</span></button></nav>}
|
||||
</main>;
|
||||
}
|
||||
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
export type Language = "de" | "en";
|
||||
|
||||
export const translations = {
|
||||
de: {
|
||||
automaticActive: "Deine Motive wechseln automatisch",
|
||||
automaticPaused: "Automatischer Wechsel ist pausiert",
|
||||
settings: "Einstellungen",
|
||||
currentWallpaper: "Aktuelles Hintergrundbild",
|
||||
currentImage: "Aktuelles Motiv",
|
||||
nextImage: "Nächstes Motiv",
|
||||
changeOnWake: "Bei jedem Aktivieren wechseln",
|
||||
selectImages: "Bilder auswählen",
|
||||
pleaseWait: "Bitte warten …",
|
||||
collection: "Meine Sammlung",
|
||||
image: "Bild",
|
||||
images: "Bilder",
|
||||
motif: "Motiv",
|
||||
noImages: "Noch keine Bilder ausgewählt",
|
||||
collectionHint: "Tippe auf die Bildanzahl, um deine Galerie zu öffnen.",
|
||||
shuffle: "Zufällige Reihenfolge",
|
||||
lockScreenOnly: "Nur Sperrbildschirm",
|
||||
settingsIntro: "Lege fest, wie WallpaperFlow im Hintergrund arbeitet.",
|
||||
language: "Sprache",
|
||||
german: "Deutsch",
|
||||
english: "Englisch",
|
||||
noImageLimit: "Ohne Bilderlimit",
|
||||
privacyInfo: "Deine Auswahl wird privat auf dem Gerät gespeichert. Die einzige Grenze ist der freie Speicherplatz.",
|
||||
home: "Start",
|
||||
back: "Zurück",
|
||||
addImages: "Bilder hinzufügen",
|
||||
backToGallery: "Zurück zur Galerie",
|
||||
editImage: "Bild anpassen",
|
||||
savedForImage: "Wird nur für dieses Bild gespeichert",
|
||||
saveCrop: "Bildausschnitt speichern",
|
||||
myImages: "Meine Bilder",
|
||||
current: "Aktuell",
|
||||
adjustImage: "Bild anpassen",
|
||||
deleteImage: "Bild löschen",
|
||||
emptyCollection: "Deine Sammlung ist leer",
|
||||
emptyCollectionText: "Füge Bilder hinzu, die automatisch als Sperrbildschirm wechseln sollen.",
|
||||
galleryLoading: "Galerie wird geladen …",
|
||||
loadMore: "Weitere Bilder laden",
|
||||
editCrop: "Bildausschnitt bearbeiten",
|
||||
gestureLabel: "Bild mit einem Finger verschieben und mit zwei Fingern zoomen",
|
||||
cropPreview: "Vorschau des Bildausschnitts",
|
||||
previewDate: "Donnerstag, 20. August",
|
||||
imageFit: "Bildanpassung",
|
||||
fill: "Ausfüllen",
|
||||
fit: "Einpassen",
|
||||
adjustOnScreen: "Direkt im Bildschirm anpassen",
|
||||
gestureHelp: "Mit einem Finger verschieben · mit zwei Fingern zoomen",
|
||||
reset: "Zurücksetzen",
|
||||
deleteConfirm: "Möchtest du dieses Bild wirklich aus deiner Sammlung löschen?",
|
||||
galleryLoadFailed: "Galerie konnte nicht geladen werden",
|
||||
automaticEnabled: "Automatischer Wechsel ist aktiv",
|
||||
automaticDisabled: "Automatischer Wechsel pausiert",
|
||||
settingSaved: "Einstellung gespeichert",
|
||||
androidOnly: "Diese Funktion ist auf Android verfügbar",
|
||||
imagesAdded: "Bilder wurden zur Sammlung hinzugefügt",
|
||||
selectionCancelled: "Auswahl abgebrochen",
|
||||
imageSelectionAndroid: "Bildauswahl ist auf Android verfügbar",
|
||||
imageDeleted: "Bild wurde gelöscht",
|
||||
imageDeleteFailed: "Bild konnte nicht gelöscht werden",
|
||||
cropSaved: "Bildausschnitt wurde gespeichert",
|
||||
cropSaveFailed: "Bildausschnitt konnte nicht gespeichert werden",
|
||||
wallpaperUpdated: "Sperrbildschirm wurde aktualisiert",
|
||||
},
|
||||
en: {
|
||||
automaticActive: "Your wallpapers change automatically",
|
||||
automaticPaused: "Automatic rotation is paused",
|
||||
settings: "Settings",
|
||||
currentWallpaper: "Current wallpaper",
|
||||
currentImage: "Current image",
|
||||
nextImage: "Next image",
|
||||
changeOnWake: "Change whenever the screen wakes",
|
||||
selectImages: "Select images",
|
||||
pleaseWait: "Please wait …",
|
||||
collection: "My collection",
|
||||
image: "image",
|
||||
images: "images",
|
||||
motif: "Image",
|
||||
noImages: "No images selected yet",
|
||||
collectionHint: "Tap the image count to open your gallery.",
|
||||
shuffle: "Shuffle order",
|
||||
lockScreenOnly: "Lock screen only",
|
||||
settingsIntro: "Choose how WallpaperFlow works in the background.",
|
||||
language: "Language",
|
||||
german: "German",
|
||||
english: "English",
|
||||
noImageLimit: "No image limit",
|
||||
privacyInfo: "Your selection stays private on this device. The only limit is the available storage.",
|
||||
home: "Home",
|
||||
back: "Back",
|
||||
addImages: "Add images",
|
||||
backToGallery: "Back to gallery",
|
||||
editImage: "Adjust image",
|
||||
savedForImage: "Saved only for this image",
|
||||
saveCrop: "Save image crop",
|
||||
myImages: "My images",
|
||||
current: "Current",
|
||||
adjustImage: "Adjust image",
|
||||
deleteImage: "Delete image",
|
||||
emptyCollection: "Your collection is empty",
|
||||
emptyCollectionText: "Add images that should rotate automatically on your lock screen.",
|
||||
galleryLoading: "Loading gallery …",
|
||||
loadMore: "Load more images",
|
||||
editCrop: "Edit image crop",
|
||||
gestureLabel: "Move the image with one finger and zoom with two fingers",
|
||||
cropPreview: "Image crop preview",
|
||||
previewDate: "Thursday, August 20",
|
||||
imageFit: "Image fit",
|
||||
fill: "Fill",
|
||||
fit: "Fit",
|
||||
adjustOnScreen: "Adjust directly on screen",
|
||||
gestureHelp: "Move with one finger · zoom with two fingers",
|
||||
reset: "Reset",
|
||||
deleteConfirm: "Do you really want to delete this image from your collection?",
|
||||
galleryLoadFailed: "The gallery could not be loaded",
|
||||
automaticEnabled: "Automatic rotation is active",
|
||||
automaticDisabled: "Automatic rotation is paused",
|
||||
settingSaved: "Setting saved",
|
||||
androidOnly: "This feature is available on Android",
|
||||
imagesAdded: "Images added to your collection",
|
||||
selectionCancelled: "Selection cancelled",
|
||||
imageSelectionAndroid: "Image selection is available on Android",
|
||||
imageDeleted: "Image deleted",
|
||||
imageDeleteFailed: "The image could not be deleted",
|
||||
cropSaved: "Image crop saved",
|
||||
cropSaveFailed: "The image crop could not be saved",
|
||||
wallpaperUpdated: "Lock screen updated",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type TranslationKey = keyof typeof translations.de;
|
||||
|
||||
export function initialLanguage(): Language {
|
||||
const stored = window.localStorage.getItem("wallpaperflow-language");
|
||||
if (stored === "de" || stored === "en") return stored;
|
||||
return window.navigator.language.toLowerCase().startsWith("de") ? "de" : "en";
|
||||
}
|
||||
+28
-5
@@ -13,6 +13,10 @@ export type GalleryImage = {
|
||||
id: string;
|
||||
url: string;
|
||||
selected: boolean;
|
||||
cropMode: "cover" | "contain";
|
||||
cropZoom: number;
|
||||
cropPositionX: number;
|
||||
cropPositionY: number;
|
||||
};
|
||||
|
||||
export type GalleryPage = {
|
||||
@@ -30,6 +34,8 @@ const demoState: WallpaperState = {
|
||||
};
|
||||
|
||||
const inTauri = () => "__TAURI_INTERNALS__" in window;
|
||||
const demoCrops = new Map<string, Pick<GalleryImage, "cropMode" | "cropZoom" | "cropPositionX" | "cropPositionY">>();
|
||||
const defaultCrop = { cropMode: "cover" as const, cropZoom: 1, cropPositionX: 0.5, cropPositionY: 0.5 };
|
||||
|
||||
export async function getState(): Promise<WallpaperState> {
|
||||
return inTauri() ? invoke<WallpaperState>("plugin:wallpaper|get_state") : demoState;
|
||||
@@ -39,11 +45,10 @@ export async function getGallery(offset = 0, limit = 48): Promise<GalleryPage> {
|
||||
if (inTauri()) return invoke<GalleryPage>("plugin:wallpaper|get_gallery", { offset, limit });
|
||||
return {
|
||||
total: demoState.imageUrls.length,
|
||||
items: demoState.imageUrls.slice(offset, offset + limit).map((url, index) => ({
|
||||
id: `demo-${offset + index}`,
|
||||
url,
|
||||
selected: offset + index === demoState.currentIndex,
|
||||
})),
|
||||
items: demoState.imageUrls.slice(offset, offset + limit).map((url, index) => {
|
||||
const id = `demo-${offset + index}`;
|
||||
return { id, url, selected: offset + index === demoState.currentIndex, ...(demoCrops.get(id) ?? defaultCrop) };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -63,6 +68,24 @@ export async function deleteImage(id: string): Promise<WallpaperState> {
|
||||
return { ...demoState, imageUrls: [...demoState.imageUrls] };
|
||||
}
|
||||
|
||||
export async function setImageCrop(image: GalleryImage): Promise<GalleryImage> {
|
||||
const payload = {
|
||||
id: image.id,
|
||||
mode: image.cropMode,
|
||||
zoom: image.cropZoom,
|
||||
positionX: image.cropPositionX,
|
||||
positionY: image.cropPositionY,
|
||||
};
|
||||
if (inTauri()) return invoke<GalleryImage>("plugin:wallpaper|set_image_crop", payload);
|
||||
demoCrops.set(image.id, {
|
||||
cropMode: image.cropMode,
|
||||
cropZoom: image.cropZoom,
|
||||
cropPositionX: image.cropPositionX,
|
||||
cropPositionY: image.cropPositionY,
|
||||
});
|
||||
return { ...image };
|
||||
}
|
||||
|
||||
export async function setSetting(name: "enabled" | "shuffle" | "lockScreenOnly", value: boolean) {
|
||||
if (!inTauri()) return { ...demoState, [name]: value };
|
||||
return invoke<WallpaperState>("plugin:wallpaper|set_setting", { name, value });
|
||||
|
||||
@@ -9,9 +9,26 @@ header p { margin: 0; font-size: 13px; color: #657068; font-weight: 500; }
|
||||
.icon-button { width: 44px; height: 44px; border: 0; border-radius: 50%; background: #edf2eb; color: #31523b; display: grid; place-items: center; }
|
||||
.icon-button svg { width: 21px; }
|
||||
.icon-button:disabled { opacity: .55; }
|
||||
.app-header { gap: 12px; overflow: hidden; }
|
||||
.brand { min-width: 0; flex: 1; display: flex; align-items: center; gap: 11px; }
|
||||
.brand-logo { width: 46px; height: 46px; flex: 0 0 auto; border-radius: 13px; box-shadow: 0 5px 15px rgba(20,93,50,.18); }
|
||||
.brand-copy { min-width: 0; }
|
||||
.brand-copy h1 { margin-bottom: 3px; overflow: hidden; font-size: clamp(24px, 7vw, 30px); letter-spacing: -1.2px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.brand-copy p { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.settings-shortcut { flex: 0 0 46px; width: 46px; height: 46px; color: white; background: var(--green); box-shadow: 0 7px 18px rgba(20,93,50,.24); }
|
||||
.settings-shortcut.selected { color: var(--green); background: #dcebdd; box-shadow: inset 0 0 0 2px rgba(20,93,50,.12); }
|
||||
.settings-shortcut:active { transform: scale(.94); }
|
||||
.gallery-header { gap: 14px; }
|
||||
.gallery-header > div { flex: 1; }
|
||||
.gallery-header h1 { font-size: 25px; letter-spacing: -.9px; margin-bottom: 3px; }
|
||||
@media (max-width: 360px) {
|
||||
header { padding-left: 16px; padding-right: 16px; }
|
||||
.brand { gap: 8px; }
|
||||
.brand-logo { width: 40px; height: 40px; border-radius: 11px; }
|
||||
.brand-copy h1 { font-size: 23px; }
|
||||
.brand-copy p { font-size: 11px; }
|
||||
.settings-shortcut { flex-basis: 42px; width: 42px; height: 42px; }
|
||||
}
|
||||
.content { padding: 0 20px; }
|
||||
.hero { position: relative; height: 294px; margin: 0 0 15px; border-radius: 26px; isolation: isolate; }
|
||||
.hero img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; border-radius: inherit; z-index: 1; }
|
||||
@@ -47,6 +64,13 @@ h2 { margin: 0; font-size: 21px; letter-spacing: -.6px; }
|
||||
.hint { margin: 8px 0 12px; text-align: center; font-size: 11px; color: #7a837d; }
|
||||
.settings-list { border-top: 1px solid var(--line); }
|
||||
.settings-page > p { color: #667069; margin: 8px 0 24px; font-size: 14px; }
|
||||
.language-setting { min-height: 68px; margin-bottom: 8px; display: flex; align-items: center; gap: 12px; border-bottom: 1px solid var(--line); }
|
||||
.language-setting > div:nth-child(2) { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 2px; }
|
||||
.language-setting strong { font-size: 15px; }
|
||||
.language-setting span { color: #768078; font-size: 11px; }
|
||||
.language-toggle { padding: 3px; display: flex; border-radius: 12px; background: #e8eee7; }
|
||||
.language-toggle button { width: 39px; height: 31px; padding: 0; border: 0; border-radius: 9px; color: #657068; background: transparent; font-size: 12px; font-weight: 800; }
|
||||
.language-toggle button.active { color: white; background: var(--green); box-shadow: 0 2px 7px rgba(20,93,50,.2); }
|
||||
.settings-page .settings-list { margin-top: 8px; }
|
||||
.info { margin-top: 28px; padding: 20px; background: #eaf2e8; border-radius: 20px; }
|
||||
.info h3 { color: var(--green); margin: 0 0 6px; font-size: 16px; }
|
||||
@@ -64,6 +88,8 @@ nav svg { width: 21px; }
|
||||
.delete-button { position: absolute; top: 7px; right: 7px; width: 32px; height: 32px; padding: 0; border: 0; border-radius: 50%; display: grid; place-items: center; color: white; background: rgba(94, 20, 20, .86); box-shadow: 0 3px 12px rgba(0,0,0,.24); }
|
||||
.delete-button svg { width: 16px; }
|
||||
.delete-button:disabled { opacity: .5; }
|
||||
.edit-button { position: absolute; right: 7px; bottom: 7px; width: 32px; height: 32px; padding: 0; border: 0; border-radius: 50%; display: grid; place-items: center; color: var(--green); background: rgba(255,255,255,.92); box-shadow: 0 3px 12px rgba(0,0,0,.2); }
|
||||
.edit-button svg { width: 16px; }
|
||||
.current-badge { position: absolute; left: 6px; bottom: 6px; display: flex; align-items: center; gap: 3px; padding: 5px 7px; border-radius: 99px; background: rgba(20, 93, 50, .9); color: white; font-size: 9px; font-weight: 750; }
|
||||
.current-badge svg { width: 11px; height: 11px; }
|
||||
.gallery-empty { min-height: 58vh; padding: 40px 18px; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; color: #68736b; }
|
||||
@@ -73,6 +99,30 @@ nav svg { width: 21px; }
|
||||
.gallery-empty .primary { max-width: 260px; }
|
||||
.gallery-status { padding: 28px 0; text-align: center; color: #6d776f; font-size: 13px; }
|
||||
.load-more { width: 100%; height: 48px; margin-top: 18px; border: 1px solid #b9c8bb; border-radius: 15px; color: var(--green); background: white; font-weight: 750; }
|
||||
.save-crop { background: var(--green); color: white; }
|
||||
.crop-editor { padding-bottom: max(28px, env(safe-area-inset-bottom)); }
|
||||
.phone-preview { position: relative; width: min(72vw, 270px); aspect-ratio: 9 / 19.5; margin: 0 auto 22px; overflow: hidden; border: 7px solid #202621; border-radius: 34px; background: #090b09; box-shadow: 0 18px 38px rgba(20,35,24,.22); }
|
||||
.phone-preview > img { width: 100%; height: 100%; display: block; transition: transform .16s ease, object-position .16s ease; }
|
||||
.phone-preview.interactive { touch-action: none; user-select: none; cursor: grab; }
|
||||
.phone-preview.interactive:active { cursor: grabbing; }
|
||||
.phone-preview.interactive > img { pointer-events: none; user-select: none; transition: none; }
|
||||
.preview-clock { position: absolute; z-index: 2; top: 55px; left: 0; right: 0; color: white; text-align: center; font-size: 42px; line-height: 1; font-weight: 300; text-shadow: 0 2px 10px rgba(0,0,0,.45); pointer-events: none; }
|
||||
.preview-clock span { display: block; margin-top: 7px; font-size: 10px; font-weight: 600; }
|
||||
.crop-controls { padding: 17px; border: 1px solid var(--line); border-radius: 20px; background: white; }
|
||||
.fit-toggle { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; padding: 4px; margin-bottom: 17px; border-radius: 13px; background: #edf2eb; }
|
||||
.fit-toggle button { height: 38px; border: 0; border-radius: 10px; color: #68736b; background: transparent; font-size: 13px; font-weight: 750; }
|
||||
.fit-toggle button.active { color: white; background: var(--green); box-shadow: 0 3px 9px rgba(20,93,50,.18); }
|
||||
.crop-controls label { display: block; margin-top: 13px; color: #58635b; font-size: 12px; font-weight: 700; }
|
||||
.crop-controls label span { display: flex; justify-content: space-between; }
|
||||
.crop-controls label strong { color: var(--green); }
|
||||
.crop-controls input[type="range"] { width: 100%; margin: 8px 0 0; accent-color: var(--green); }
|
||||
.direct-crop-heading { display: flex; align-items: center; justify-content: space-between; color: #526158; font-size: 13px; font-weight: 750; }
|
||||
.direct-crop-heading span { display: flex; align-items: center; gap: 7px; }
|
||||
.direct-crop-heading svg { width: 17px; }
|
||||
.direct-crop-heading strong { color: var(--green); }
|
||||
.direct-crop-help { margin: 5px 0 11px; color: #7a837d; font-size: 10px; line-height: 1.4; }
|
||||
.reset-crop { width: 100%; height: 42px; margin-top: 16px; border: 0; border-radius: 12px; color: #526158; background: #edf2eb; display: flex; justify-content: center; align-items: center; gap: 7px; font-size: 12px; font-weight: 750; }
|
||||
.reset-crop svg { width: 16px; }
|
||||
@media (min-width: 700px) { .app-shell { margin-top: 20px; min-height: calc(100vh - 40px); border-radius: 32px; overflow: hidden; } nav { bottom: 20px; border-radius: 0 0 32px 32px; } }
|
||||
@media (max-height: 740px) { .hero { height: 235px; } }
|
||||
@media (prefers-reduced-motion: reduce) { * { scroll-behavior: auto !important; transition: none !important; } }
|
||||
|
||||
Reference in New Issue
Block a user