Add an edit/delete manage screen to the Android app
A new Manage screen (reached from the Post screen) lists published posts with thumbnails and lets you edit a post's caption, date and tags or delete it to the server's trash, over the same /api/photos, /api/edit and /api/delete endpoints the web /manage uses. UploadClient gains listPhotos/editPost/deletePost/fetchThumbnail, parsing JSON with the built-in org.json (no new dependency) and posting edits/deletes as urlencoded forms. The app sends no Origin or Sec-Fetch-Site header, so it passes the server's same-origin CSRF check as a non-browser client; Basic auth is already attached on every request. UploadViewModel holds the manage state, loads the list, fetches thumbnails in the background, and applies edits/deletes optimistically.
This commit is contained in:
1 parent
e67d25b191
commit
225bdc9b4f
7 files changed
+585
-11
No files matched your search
@@ -189,7 +189,7 @@ go run . --serve
|
|||||||
|
|
||||||
## Android app
|
## Android app
|
||||||
|
|
||||||
Kotlin + Jetpack Compose uploader: pick or share a photo, add caption and date, post. Credentials (HTTP Basic) are sealed with an Android Keystore AES-GCM key. See `android/README.md` for the detail.
|
Kotlin + Jetpack Compose uploader: pick or share one or many photos, add caption/date/tags, post via a durable WorkManager queue. It also has a **Manage screen** (from the Post screen's "Manage" button) that lists published posts with thumbnails and edits caption/date/tags or deletes (to trash) via the same `/api/photos`, `/api/edit`, `/api/delete` endpoints the web `/manage` uses. The manage calls go through `UploadClient` (OkHttp + `org.json`, no extra deps); sending no `Origin`/`Sec-Fetch-Site` header, the app passes the server's same-origin CSRF check as a non-browser client. Credentials (HTTP Basic) are sealed with an Android Keystore AES-GCM key. See `android/README.md` for the detail.
|
||||||
|
|
||||||
Its own flake, deliberately separate from the root one so NixOS consumers of `nixosModules.default` don't pull the Android SDK into their lock:
|
Its own flake, deliberately separate from the root one so NixOS consumers of `nixosModules.default` don't pull the Android SDK into their lock:
|
||||||
|
|
||||||
@@ -262,7 +262,7 @@ nix build # succeeds
|
|||||||
## What's not done yet
|
## What's not done yet
|
||||||
|
|
||||||
- ActivityPub federation (planned later — start with RSS/Atom)
|
- ActivityPub federation (planned later — start with RSS/Atom)
|
||||||
- Edit/delete/trash in the Android and Qt apps (web `/manage` only so far; the `/api/` endpoints are there for the apps to adopt)
|
- Edit/delete in the **Qt desktop** app (web `/manage` and the Android app have it; the `/api/` endpoints are there for Qt to adopt). Restoring from trash is still web-only on all clients.
|
||||||
- Any auth beyond the app's htpasswd Basic auth (no sessions, no per-user scopes)
|
- Any auth beyond the app's htpasswd Basic auth (no sessions, no per-user scopes)
|
||||||
- The Android app has never been run on real hardware
|
- The Android app has never been run on real hardware
|
||||||
- Photo-page swipe has never been tested on a real touchscreen; the iOS Safari edge-gesture interaction is the untested part
|
- Photo-page swipe has never been tested on a real touchscreen; the iOS Safari edge-gesture interaction is the untested part
|
||||||
+7
-4
@@ -1,11 +1,14 @@
|
|||||||
# Gallery Upload — Android client
|
# Gallery Upload — Android client
|
||||||
|
|
||||||
Native uploader for the photogallery `POST /upload` endpoint. Pick or share a
|
Native uploader and manager for the photogallery server. Pick or share one or
|
||||||
photo, add a caption and date, post. Credentials are entered once and sealed
|
many photos, add caption/date/tags, and post. A **Manage** screen lists the
|
||||||
with a key held in the Android Keystore.
|
published posts and edits their caption/date/tags or deletes them (to the
|
||||||
|
server's trash) over the same `/api/*` endpoints the web `/manage` uses.
|
||||||
|
Credentials are entered once and sealed with a key held in the Android Keystore.
|
||||||
|
|
||||||
The server is not modified by this app. Everything it needs, it does client
|
The server is not modified by this app. Everything it needs, it does client
|
||||||
side.
|
side; the management calls send no browser headers, so they pass the server's
|
||||||
|
same-origin (CSRF) check as a non-browser client.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
|
import ws.inflo.photogallery.ui.ManageScreen
|
||||||
import ws.inflo.photogallery.ui.PhotoGalleryTheme
|
import ws.inflo.photogallery.ui.PhotoGalleryTheme
|
||||||
import ws.inflo.photogallery.ui.PostScreen
|
import ws.inflo.photogallery.ui.PostScreen
|
||||||
import ws.inflo.photogallery.ui.SettingsScreen
|
import ws.inflo.photogallery.ui.SettingsScreen
|
||||||
@@ -37,6 +38,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
PhotoGalleryTheme {
|
PhotoGalleryTheme {
|
||||||
// Land on settings until there is somewhere to post to.
|
// Land on settings until there is somewhere to post to.
|
||||||
var showSettings by rememberSaveable { mutableStateOf(!viewModel.isConfigured) }
|
var showSettings by rememberSaveable { mutableStateOf(!viewModel.isConfigured) }
|
||||||
|
var showManage by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
val picker = rememberLauncherForActivityResult(
|
val picker = rememberLauncherForActivityResult(
|
||||||
ActivityResultContracts.PickMultipleVisualMedia(),
|
ActivityResultContracts.PickMultipleVisualMedia(),
|
||||||
@@ -57,8 +59,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showSettings) {
|
when {
|
||||||
SettingsScreen(
|
showSettings -> SettingsScreen(
|
||||||
state = viewModel.config,
|
state = viewModel.config,
|
||||||
onBaseUrlChange = viewModel::setBaseUrl,
|
onBaseUrlChange = viewModel::setBaseUrl,
|
||||||
onUsernameChange = viewModel::setUsername,
|
onUsernameChange = viewModel::setUsername,
|
||||||
@@ -70,8 +72,17 @@ class MainActivity : ComponentActivity() {
|
|||||||
null
|
null
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
PostScreen(
|
showManage -> ManageScreen(
|
||||||
|
state = viewModel.manage,
|
||||||
|
onBack = { showManage = false },
|
||||||
|
onRefresh = viewModel::loadManage,
|
||||||
|
onSave = viewModel::saveEdit,
|
||||||
|
onDelete = viewModel::deletePost,
|
||||||
|
onDismissNotice = viewModel::clearManageNotice,
|
||||||
|
)
|
||||||
|
|
||||||
|
else -> PostScreen(
|
||||||
state = viewModel.post,
|
state = viewModel.post,
|
||||||
onPickImages = {
|
onPickImages = {
|
||||||
picker.launch(
|
picker.launch(
|
||||||
@@ -87,6 +98,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
onSubmit = viewModel::submit,
|
onSubmit = viewModel::submit,
|
||||||
onReset = viewModel::clear,
|
onReset = viewModel::clear,
|
||||||
onOpenSettings = { showSettings = true },
|
onOpenSettings = { showSettings = true },
|
||||||
|
onOpenManage = {
|
||||||
|
viewModel.loadManage()
|
||||||
|
showManage = true
|
||||||
|
},
|
||||||
onOpenUrl = ::openUrl,
|
onOpenUrl = ::openUrl,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package ws.inflo.photogallery.net
|
package ws.inflo.photogallery.net
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
import okhttp3.Credentials
|
import okhttp3.Credentials
|
||||||
|
import okhttp3.FormBody
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.MultipartBody
|
import okhttp3.MultipartBody
|
||||||
@@ -8,6 +11,8 @@ import okhttp3.OkHttpClient
|
|||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody.Companion.asRequestBody
|
import okhttp3.RequestBody.Companion.asRequestBody
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONException
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
@@ -25,6 +30,22 @@ sealed interface UploadOutcome {
|
|||||||
data class Transient(val message: String) : UploadOutcome
|
data class Transient(val message: String) : UploadOutcome
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One published post, as returned by GET /api/photos. */
|
||||||
|
data class RemotePhoto(
|
||||||
|
val slug: String,
|
||||||
|
val caption: String,
|
||||||
|
val date: String, // YYYY-MM-DD
|
||||||
|
val tags: List<String>,
|
||||||
|
val thumb: String, // site-relative, e.g. "/thumbs/foo.jpg"
|
||||||
|
val url: String, // site-relative, e.g. "/photo/foo/"
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Result of listing posts for the manage screen. */
|
||||||
|
sealed interface ListResult {
|
||||||
|
data class Ok(val photos: List<RemotePhoto>) : ListResult
|
||||||
|
data class Err(val message: String) : ListResult
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Talks to the gallery's POST /upload endpoint.
|
* Talks to the gallery's POST /upload endpoint.
|
||||||
*
|
*
|
||||||
@@ -122,6 +143,123 @@ class UploadClient(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists every published post for the manage screen. The manage endpoints
|
||||||
|
* sit behind the same Basic auth as /upload; the app sends no Origin or
|
||||||
|
* Sec-Fetch-Site header, so it passes the server's same-origin (CSRF) check
|
||||||
|
* as a non-browser client.
|
||||||
|
*/
|
||||||
|
fun listPhotos(): ListResult {
|
||||||
|
val request = Request.Builder().url("$base/api/photos").get().build()
|
||||||
|
return try {
|
||||||
|
client.newCall(request).execute().use { response ->
|
||||||
|
val text = response.body?.string().orEmpty()
|
||||||
|
when {
|
||||||
|
response.isSuccessful -> parsePhotos(text)
|
||||||
|
response.code == 401 || response.code == 403 ->
|
||||||
|
ListResult.Err("Wrong username or password")
|
||||||
|
else -> ListResult.Err("Server returned ${response.code}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
ListResult.Err(e.message ?: "Network error")
|
||||||
|
} catch (e: JSONException) {
|
||||||
|
ListResult.Err("Could not read the server's response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parsePhotos(body: String): ListResult {
|
||||||
|
val arr = JSONArray(body)
|
||||||
|
val out = ArrayList<RemotePhoto>(arr.length())
|
||||||
|
for (i in 0 until arr.length()) {
|
||||||
|
val o = arr.getJSONObject(i)
|
||||||
|
val tagsArr = o.optJSONArray("tags")
|
||||||
|
val tags = ArrayList<String>()
|
||||||
|
if (tagsArr != null) {
|
||||||
|
for (j in 0 until tagsArr.length()) tags.add(tagsArr.getString(j))
|
||||||
|
}
|
||||||
|
out.add(
|
||||||
|
RemotePhoto(
|
||||||
|
slug = o.getString("slug"),
|
||||||
|
caption = o.optString("caption"),
|
||||||
|
date = o.optString("date"),
|
||||||
|
tags = tags,
|
||||||
|
thumb = o.optString("thumb"),
|
||||||
|
url = o.optString("url"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return ListResult.Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewrites a post's sidecar. The slug (and its URL) never changes. Returns
|
||||||
|
* null on success, or a human-readable error.
|
||||||
|
*/
|
||||||
|
fun editPost(slug: String, caption: String, date: String, tags: String): String? {
|
||||||
|
val form = FormBody.Builder()
|
||||||
|
.add("slug", slug)
|
||||||
|
.add("caption", caption)
|
||||||
|
.add("date", date)
|
||||||
|
.add("tags", tags)
|
||||||
|
.build()
|
||||||
|
val request = Request.Builder().url("$base/api/edit").post(form).build()
|
||||||
|
return simpleResult(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves a post to the server's trash (reversible there). Returns null on
|
||||||
|
* success, or a human-readable error — including the case where the server
|
||||||
|
* reports that nothing was deleted.
|
||||||
|
*/
|
||||||
|
fun deletePost(slug: String): String? {
|
||||||
|
val form = FormBody.Builder().add("slug", slug).build()
|
||||||
|
val request = Request.Builder().url("$base/api/delete").post(form).build()
|
||||||
|
return try {
|
||||||
|
client.newCall(request).execute().use { response ->
|
||||||
|
val text = response.body?.string().orEmpty()
|
||||||
|
when {
|
||||||
|
response.isSuccessful -> {
|
||||||
|
val deleted = runCatching {
|
||||||
|
org.json.JSONObject(text).optInt("deleted", 0)
|
||||||
|
}.getOrDefault(0)
|
||||||
|
if (deleted > 0) null else "Nothing deleted — not found on the server"
|
||||||
|
}
|
||||||
|
response.code == 401 || response.code == 403 -> "Wrong username or password"
|
||||||
|
else -> text.trim().ifEmpty { "Server returned ${response.code}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
e.message ?: "Network error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetches a thumbnail by its site-relative [path], or null on any failure. */
|
||||||
|
fun fetchThumbnail(path: String): Bitmap? {
|
||||||
|
val request = Request.Builder().url("$base$path").get().build()
|
||||||
|
return try {
|
||||||
|
client.newCall(request).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) return null
|
||||||
|
response.body?.byteStream()?.let { BitmapFactory.decodeStream(it) }
|
||||||
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun simpleResult(request: Request): String? = try {
|
||||||
|
client.newCall(request).execute().use { response ->
|
||||||
|
val text = response.body?.string().orEmpty()
|
||||||
|
when {
|
||||||
|
response.isSuccessful -> null
|
||||||
|
response.code == 401 || response.code == 403 -> "Wrong username or password"
|
||||||
|
else -> text.trim().ifEmpty { "Server returned ${response.code}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
e.message ?: "Network error"
|
||||||
|
}
|
||||||
|
|
||||||
/** Server replies "uploaded <slug>.<ext>"; recover the slug from that. */
|
/** Server replies "uploaded <slug>.<ext>"; recover the slug from that. */
|
||||||
private fun parseSlug(responseBody: String, fallback: String): String {
|
private fun parseSlug(responseBody: String, fallback: String): String {
|
||||||
val name = responseBody.trim().removePrefix("uploaded ").trim()
|
val name = responseBody.trim().removePrefix("uploaded ").trim()
|
||||||
|
|||||||
@@ -0,0 +1,294 @@
|
|||||||
|
package ws.inflo.photogallery.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.DatePicker
|
||||||
|
import androidx.compose.material3.DatePickerDialog
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.rememberDatePickerState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
private val DISPLAY_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("d MMM yyyy")
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ManageScreen(
|
||||||
|
state: ManageUiState,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onRefresh: () -> Unit,
|
||||||
|
onSave: (slug: String, caption: String, date: LocalDate, tags: String) -> Unit,
|
||||||
|
onDelete: (slug: String) -> Unit,
|
||||||
|
onDismissNotice: () -> Unit,
|
||||||
|
) {
|
||||||
|
var editing by remember { mutableStateOf<ManagePost?>(null) }
|
||||||
|
var deleting by remember { mutableStateOf<ManagePost?>(null) }
|
||||||
|
|
||||||
|
Surface(color = MaterialTheme.colorScheme.background, modifier = Modifier.fillMaxSize()) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(20.dp),
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text("Manage posts", style = MaterialTheme.typography.headlineSmall, color = GruvGreen)
|
||||||
|
Row {
|
||||||
|
TextButton(onClick = onRefresh) { Text("Refresh") }
|
||||||
|
TextButton(onClick = onBack) { Text("Back") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
state.error?.let { msg ->
|
||||||
|
item { Notice(msg, GruvRed, onDismissNotice) }
|
||||||
|
}
|
||||||
|
state.message?.let { msg ->
|
||||||
|
item { Notice(msg, GruvAqua, onDismissNotice) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.loading) {
|
||||||
|
item {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(120.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) { CircularProgressIndicator(color = GruvAqua) }
|
||||||
|
}
|
||||||
|
} else if (state.items.isEmpty()) {
|
||||||
|
item {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(120.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) { Text("No posts.", color = GruvFg4) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items(state.items, key = { it.slug }) { post ->
|
||||||
|
PostRow(
|
||||||
|
post = post,
|
||||||
|
onEdit = { editing = post },
|
||||||
|
onDelete = { deleting = post },
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
editing?.let { post ->
|
||||||
|
EditDialog(
|
||||||
|
post = post,
|
||||||
|
onDismiss = { editing = null },
|
||||||
|
onSave = { caption, date, tags ->
|
||||||
|
onSave(post.slug, caption, date, tags)
|
||||||
|
editing = null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
deleting?.let { post ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { deleting = null },
|
||||||
|
title = { Text("Move to trash?") },
|
||||||
|
text = { Text("\"${post.caption.ifBlank { post.slug }}\" will be moved to the server's trash.") },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
onDelete(post.slug)
|
||||||
|
deleting = null
|
||||||
|
}) { Text("Delete", color = GruvRed) }
|
||||||
|
},
|
||||||
|
dismissButton = { TextButton(onClick = { deleting = null }) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Notice(message: String, color: androidx.compose.ui.graphics.Color, onDismiss: () -> Unit) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(GruvBg1)
|
||||||
|
.padding(12.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(message, color = color, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
TextButton(onClick = onDismiss) { Text("Dismiss") }
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PostRow(post: ManagePost, onEdit: () -> Unit, onDelete: () -> Unit) {
|
||||||
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(72.dp)
|
||||||
|
.background(GruvBg1),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
val preview = post.preview
|
||||||
|
if (preview != null) {
|
||||||
|
Image(
|
||||||
|
bitmap = preview.asImageBitmap(),
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
post.caption.ifBlank { post.slug },
|
||||||
|
color = GruvFg,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
Text(post.date.format(DISPLAY_DATE), color = GruvFg4, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall)
|
||||||
|
if (post.tags.isNotEmpty()) {
|
||||||
|
Text(post.tags.joinToString(" · "), color = GruvAqua, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Row {
|
||||||
|
if (post.busy) {
|
||||||
|
CircularProgressIndicator(color = GruvAqua, modifier = Modifier.size(18.dp))
|
||||||
|
} else {
|
||||||
|
OutlinedButton(onClick = onEdit) { Text("Edit") }
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
TextButton(onClick = onDelete) { Text("Delete", color = GruvRed) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EditDialog(
|
||||||
|
post: ManagePost,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onSave: (caption: String, date: LocalDate, tags: String) -> Unit,
|
||||||
|
) {
|
||||||
|
var caption by remember { mutableStateOf(post.caption) }
|
||||||
|
var tags by remember { mutableStateOf(post.tags.joinToString(", ")) }
|
||||||
|
var date by remember { mutableStateOf(post.date) }
|
||||||
|
var pickingDate by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Edit ${post.slug}") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = caption,
|
||||||
|
onValueChange = { caption = it },
|
||||||
|
label = { Text("Caption") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = tags,
|
||||||
|
onValueChange = { tags = it },
|
||||||
|
label = { Text("Tags") },
|
||||||
|
placeholder = { Text("comma-separated") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
OutlinedButton(onClick = { pickingDate = true }) {
|
||||||
|
Text(date.format(DISPLAY_DATE))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { onSave(caption, date, tags) }) { Text("Save") }
|
||||||
|
},
|
||||||
|
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
|
||||||
|
if (pickingDate) {
|
||||||
|
DatePickerModal(
|
||||||
|
initial = date,
|
||||||
|
onDismiss = { pickingDate = false },
|
||||||
|
onConfirm = {
|
||||||
|
date = it
|
||||||
|
pickingDate = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun DatePickerModal(
|
||||||
|
initial: LocalDate,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onConfirm: (LocalDate) -> Unit,
|
||||||
|
) {
|
||||||
|
val pickerState = rememberDatePickerState(
|
||||||
|
initialSelectedDateMillis = initial
|
||||||
|
.atStartOfDay(ZoneId.systemDefault())
|
||||||
|
.toInstant()
|
||||||
|
.toEpochMilli(),
|
||||||
|
)
|
||||||
|
DatePickerDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
val millis = pickerState.selectedDateMillis
|
||||||
|
if (millis != null) {
|
||||||
|
onConfirm(Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()).toLocalDate())
|
||||||
|
} else {
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
}) { Text("OK") }
|
||||||
|
},
|
||||||
|
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||||
|
) {
|
||||||
|
DatePicker(state = pickerState)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,6 +57,7 @@ fun PostScreen(
|
|||||||
onSubmit: () -> Unit,
|
onSubmit: () -> Unit,
|
||||||
onReset: () -> Unit,
|
onReset: () -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
|
onOpenManage: () -> Unit,
|
||||||
onOpenUrl: (String) -> Unit,
|
onOpenUrl: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
// Which item's date is being edited, if any.
|
// Which item's date is being edited, if any.
|
||||||
@@ -78,7 +79,10 @@ fun PostScreen(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text("Post photos", style = MaterialTheme.typography.headlineSmall, color = GruvGreen)
|
Text("Post photos", style = MaterialTheme.typography.headlineSmall, color = GruvGreen)
|
||||||
TextButton(onClick = onOpenSettings) { Text("Settings") }
|
Row {
|
||||||
|
TextButton(onClick = onOpenManage) { Text("Manage") }
|
||||||
|
TextButton(onClick = onOpenSettings) { Text("Settings") }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,11 +17,14 @@ import kotlinx.coroutines.withContext
|
|||||||
import ws.inflo.photogallery.data.LastPost
|
import ws.inflo.photogallery.data.LastPost
|
||||||
import ws.inflo.photogallery.data.SettingsStore
|
import ws.inflo.photogallery.data.SettingsStore
|
||||||
import ws.inflo.photogallery.image.ImagePrep
|
import ws.inflo.photogallery.image.ImagePrep
|
||||||
|
import ws.inflo.photogallery.net.ListResult
|
||||||
|
import ws.inflo.photogallery.net.RemotePhoto
|
||||||
import ws.inflo.photogallery.net.UploadClient
|
import ws.inflo.photogallery.net.UploadClient
|
||||||
import ws.inflo.photogallery.work.UploadQueue
|
import ws.inflo.photogallery.work.UploadQueue
|
||||||
import ws.inflo.photogallery.work.UploadWorker
|
import ws.inflo.photogallery.work.UploadWorker
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
/** Per-photo status within a batch. */
|
/** Per-photo status within a batch. */
|
||||||
@@ -60,6 +63,25 @@ data class PostUiState(
|
|||||||
items.all { it.status is ItemStatus.Success || it.status is ItemStatus.Error }
|
items.all { it.status is ItemStatus.Success || it.status is ItemStatus.Error }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One published post on the manage screen. */
|
||||||
|
data class ManagePost(
|
||||||
|
val slug: String,
|
||||||
|
val caption: String,
|
||||||
|
val date: LocalDate,
|
||||||
|
val tags: List<String>,
|
||||||
|
val thumb: String, // site-relative path
|
||||||
|
val url: String, // site-relative path
|
||||||
|
val preview: Bitmap? = null,
|
||||||
|
val busy: Boolean = false, // a save/delete is in flight
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ManageUiState(
|
||||||
|
val loading: Boolean = false,
|
||||||
|
val items: List<ManagePost> = emptyList(),
|
||||||
|
val error: String? = null,
|
||||||
|
val message: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
data class SettingsUiState(
|
data class SettingsUiState(
|
||||||
val baseUrl: String = "",
|
val baseUrl: String = "",
|
||||||
val username: String = "",
|
val username: String = "",
|
||||||
@@ -225,6 +247,104 @@ class UploadViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
post = post.copy(items = post.items.map { if (it.id == id) transform(it) else it })
|
post = post.copy(items = post.items.map { if (it.id == id) transform(it) else it })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Manage screen ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
var manage by mutableStateOf(ManageUiState())
|
||||||
|
private set
|
||||||
|
|
||||||
|
private val isoDate: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
|
||||||
|
|
||||||
|
private fun client(): UploadClient =
|
||||||
|
UploadClient(settings.baseUrl, settings.username, settings.password)
|
||||||
|
|
||||||
|
/** Loads the post list, then fills in thumbnails in the background. */
|
||||||
|
fun loadManage() {
|
||||||
|
if (!settings.isConfigured) {
|
||||||
|
manage = manage.copy(error = "Set the server URL and credentials first")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
manage = manage.copy(loading = true, error = null, message = null)
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result = withContext(Dispatchers.IO) { client().listPhotos() }
|
||||||
|
when (result) {
|
||||||
|
is ListResult.Ok -> {
|
||||||
|
manage = manage.copy(
|
||||||
|
loading = false,
|
||||||
|
items = result.photos.map { it.toPost() },
|
||||||
|
)
|
||||||
|
loadThumbnails(result.photos)
|
||||||
|
}
|
||||||
|
is ListResult.Err ->
|
||||||
|
manage = manage.copy(loading = false, error = result.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadThumbnails(photos: List<RemotePhoto>) {
|
||||||
|
for (p in photos) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val bmp = withContext(Dispatchers.IO) { client().fetchThumbnail(p.thumb) }
|
||||||
|
if (bmp != null) updateManage(p.slug) { it.copy(preview = bmp) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveEdit(slug: String, caption: String, date: LocalDate, tags: String) {
|
||||||
|
updateManage(slug) { it.copy(busy = true) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
val err = withContext(Dispatchers.IO) {
|
||||||
|
client().editPost(slug, caption, date.format(isoDate), tags)
|
||||||
|
}
|
||||||
|
if (err == null) {
|
||||||
|
updateManage(slug) {
|
||||||
|
it.copy(
|
||||||
|
busy = false,
|
||||||
|
caption = caption,
|
||||||
|
date = date,
|
||||||
|
tags = parseTagList(tags),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
manage = manage.copy(message = "Saved")
|
||||||
|
} else {
|
||||||
|
updateManage(slug) { it.copy(busy = false) }
|
||||||
|
manage = manage.copy(error = err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deletePost(slug: String) {
|
||||||
|
updateManage(slug) { it.copy(busy = true) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
val err = withContext(Dispatchers.IO) { client().deletePost(slug) }
|
||||||
|
if (err == null) {
|
||||||
|
manage = manage.copy(
|
||||||
|
items = manage.items.filterNot { it.slug == slug },
|
||||||
|
message = "Moved to trash",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
updateManage(slug) { it.copy(busy = false) }
|
||||||
|
manage = manage.copy(error = err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearManageNotice() {
|
||||||
|
manage = manage.copy(error = null, message = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun RemotePhoto.toPost(): ManagePost {
|
||||||
|
val d = runCatching { LocalDate.parse(date, isoDate) }.getOrDefault(LocalDate.now())
|
||||||
|
return ManagePost(slug = slug, caption = caption, date = d, tags = tags, thumb = thumb, url = url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local split for optimistic UI only; the server normalizes authoritatively.
|
||||||
|
private fun parseTagList(raw: String): List<String> =
|
||||||
|
raw.split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
||||||
|
|
||||||
|
private inline fun updateManage(slug: String, transform: (ManagePost) -> ManagePost) {
|
||||||
|
manage = manage.copy(items = manage.items.map { if (it.slug == slug) transform(it) else it })
|
||||||
|
}
|
||||||
|
|
||||||
// ── Settings screen ─────────────────────────────────────────────────────
|
// ── Settings screen ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
fun setBaseUrl(value: String) {
|
fun setBaseUrl(value: String) {
|
||||||
|
|||||||
Reference in new issue
Block a user