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
+7
-4
@@ -1,11 +1,14 @@
|
||||
# Gallery Upload — Android client
|
||||
|
||||
Native uploader for the photogallery `POST /upload` endpoint. Pick or share a
|
||||
photo, add a caption and date, post. Credentials are entered once and sealed
|
||||
with a key held in the Android Keystore.
|
||||
Native uploader and manager for the photogallery server. Pick or share one or
|
||||
many photos, add caption/date/tags, and post. A **Manage** screen lists the
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user