From 225bdc9b4f008b3b632afff2e2946f0d0b233254 Mon Sep 17 00:00:00 2001 From: puttaalu Date: Sun, 18 Oct 2026 23:23:39 +0200 Subject: [PATCH] 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. --- CLAUDE.md | 4 +- android/README.md | 11 +- .../ws/inflo/photogallery/MainActivity.kt | 23 +- .../ws/inflo/photogallery/net/UploadClient.kt | 138 ++++++++ .../ws/inflo/photogallery/ui/ManageScreen.kt | 294 ++++++++++++++++++ .../ws/inflo/photogallery/ui/PostScreen.kt | 6 +- .../inflo/photogallery/ui/UploadViewModel.kt | 120 +++++++ 7 files changed, 585 insertions(+), 11 deletions(-) create mode 100644 android/app/src/main/java/ws/inflo/photogallery/ui/ManageScreen.kt diff --git a/CLAUDE.md b/CLAUDE.md index b82ad0a..8496129 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -189,7 +189,7 @@ go run . --serve ## 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: @@ -262,7 +262,7 @@ nix build # succeeds ## What's not done yet - 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) - 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 diff --git a/android/README.md b/android/README.md index ed9c399..19ce205 100644 --- a/android/README.md +++ b/android/README.md @@ -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 diff --git a/android/app/src/main/java/ws/inflo/photogallery/MainActivity.kt b/android/app/src/main/java/ws/inflo/photogallery/MainActivity.kt index be34219..8af6e36 100644 --- a/android/app/src/main/java/ws/inflo/photogallery/MainActivity.kt +++ b/android/app/src/main/java/ws/inflo/photogallery/MainActivity.kt @@ -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, ) } diff --git a/android/app/src/main/java/ws/inflo/photogallery/net/UploadClient.kt b/android/app/src/main/java/ws/inflo/photogallery/net/UploadClient.kt index 2bf28d1..0957788 100644 --- a/android/app/src/main/java/ws/inflo/photogallery/net/UploadClient.kt +++ b/android/app/src/main/java/ws/inflo/photogallery/net/UploadClient.kt @@ -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, + 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) : 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(arr.length()) + for (i in 0 until arr.length()) { + val o = arr.getJSONObject(i) + val tagsArr = o.optJSONArray("tags") + val tags = ArrayList() + 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 ."; recover the slug from that. */ private fun parseSlug(responseBody: String, fallback: String): String { val name = responseBody.trim().removePrefix("uploaded ").trim() diff --git a/android/app/src/main/java/ws/inflo/photogallery/ui/ManageScreen.kt b/android/app/src/main/java/ws/inflo/photogallery/ui/ManageScreen.kt new file mode 100644 index 0000000..c11d30d --- /dev/null +++ b/android/app/src/main/java/ws/inflo/photogallery/ui/ManageScreen.kt @@ -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(null) } + var deleting by remember { mutableStateOf(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) + } +} diff --git a/android/app/src/main/java/ws/inflo/photogallery/ui/PostScreen.kt b/android/app/src/main/java/ws/inflo/photogallery/ui/PostScreen.kt index 64dc993..8c5d589 100644 --- a/android/app/src/main/java/ws/inflo/photogallery/ui/PostScreen.kt +++ b/android/app/src/main/java/ws/inflo/photogallery/ui/PostScreen.kt @@ -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)) } diff --git a/android/app/src/main/java/ws/inflo/photogallery/ui/UploadViewModel.kt b/android/app/src/main/java/ws/inflo/photogallery/ui/UploadViewModel.kt index 182a57c..233a42d 100644 --- a/android/app/src/main/java/ws/inflo/photogallery/ui/UploadViewModel.kt +++ b/android/app/src/main/java/ws/inflo/photogallery/ui/UploadViewModel.kt @@ -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, + 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 = 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) { + 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 = + 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) {