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:
puttaalu committed 2026-10-18 23:23:39 +02:00
1 parent e67d25b191
commit 225bdc9b4f
7 files changed
+585 -11

No files matched your search

@@ -20,6 +20,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import ws.inflo.photogallery.ui.ManageScreen
import ws.inflo.photogallery.ui.PhotoGalleryTheme
import ws.inflo.photogallery.ui.PostScreen
import ws.inflo.photogallery.ui.SettingsScreen
@@ -37,6 +38,7 @@ class MainActivity : ComponentActivity() {
PhotoGalleryTheme {
// Land on settings until there is somewhere to post to.
var showSettings by rememberSaveable { mutableStateOf(!viewModel.isConfigured) }
var showManage by rememberSaveable { mutableStateOf(false) }
val picker = rememberLauncherForActivityResult(
ActivityResultContracts.PickMultipleVisualMedia(),
@@ -57,8 +59,8 @@ class MainActivity : ComponentActivity() {
}
}
if (showSettings) {
SettingsScreen(
when {
showSettings -> SettingsScreen(
state = viewModel.config,
onBaseUrlChange = viewModel::setBaseUrl,
onUsernameChange = viewModel::setUsername,
@@ -70,8 +72,17 @@ class MainActivity : ComponentActivity() {
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,
onPickImages = {
picker.launch(
@@ -87,6 +98,10 @@ class MainActivity : ComponentActivity() {
onSubmit = viewModel::submit,
onReset = viewModel::clear,
onOpenSettings = { showSettings = true },
onOpenManage = {
viewModel.loadManage()
showManage = true
},
onOpenUrl = ::openUrl,
)
}
@@ -1,6 +1,9 @@
package ws.inflo.photogallery.net
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import okhttp3.Credentials
import okhttp3.FormBody
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
@@ -8,6 +11,8 @@ import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.Response
import org.json.JSONArray
import org.json.JSONException
import java.io.File
import java.io.IOException
import java.util.concurrent.TimeUnit
@@ -25,6 +30,22 @@ sealed interface 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.
*
@@ -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. */
private fun parseSlug(responseBody: String, fallback: String): String {
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,
onReset: () -> Unit,
onOpenSettings: () -> Unit,
onOpenManage: () -> Unit,
onOpenUrl: (String) -> Unit,
) {
// Which item's date is being edited, if any.
@@ -78,7 +79,10 @@ fun PostScreen(
verticalAlignment = Alignment.CenterVertically,
) {
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))
}
@@ -17,11 +17,14 @@ import kotlinx.coroutines.withContext
import ws.inflo.photogallery.data.LastPost
import ws.inflo.photogallery.data.SettingsStore
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.work.UploadQueue
import ws.inflo.photogallery.work.UploadWorker
import java.io.File
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.UUID
/** 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 }
}
/** 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(
val baseUrl: 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 })
}
// ── 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 ─────────────────────────────────────────────────────
fun setBaseUrl(value: String) {