diff --git a/app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt b/app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt index cd41155..f3d240c 100644 --- a/app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt +++ b/app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt @@ -1,5 +1,7 @@ package com.swoosh.microblog.data.api +import com.swoosh.microblog.data.model.PageWrapper +import com.swoosh.microblog.data.model.PagesResponse import com.swoosh.microblog.data.model.PostWrapper import com.swoosh.microblog.data.model.PostsResponse import okhttp3.MultipartBody @@ -40,6 +42,28 @@ interface GhostApiService { @GET("ghost/api/admin/users/me/") suspend fun getCurrentUser(): Response + // --- Pages --- + + @GET("ghost/api/admin/pages/") + suspend fun getPages( + @Query("limit") limit: String = "all", + @Query("formats") formats: String = "html,plaintext,mobiledoc" + ): Response + + @POST("ghost/api/admin/pages/") + @Headers("Content-Type: application/json") + suspend fun createPage(@Body body: PageWrapper): Response + + @PUT("ghost/api/admin/pages/{id}/") + @Headers("Content-Type: application/json") + suspend fun updatePage( + @Path("id") id: String, + @Body body: PageWrapper + ): Response + + @DELETE("ghost/api/admin/pages/{id}/") + suspend fun deletePage(@Path("id") id: String): Response + @Multipart @POST("ghost/api/admin/images/upload/") suspend fun uploadImage( diff --git a/app/src/main/java/com/swoosh/microblog/data/model/PageModels.kt b/app/src/main/java/com/swoosh/microblog/data/model/PageModels.kt new file mode 100644 index 0000000..4524b67 --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/data/model/PageModels.kt @@ -0,0 +1,26 @@ +package com.swoosh.microblog.data.model + +data class PagesResponse( + val pages: List, + val meta: Meta? +) + +data class PageWrapper( + val pages: List +) + +data class GhostPage( + val id: String? = null, + val title: String? = null, + val slug: String? = null, + val url: String? = null, + val html: String? = null, + val plaintext: String? = null, + val mobiledoc: String? = null, + val status: String? = null, + val feature_image: String? = null, + val custom_excerpt: String? = null, + val created_at: String? = null, + val updated_at: String? = null, + val published_at: String? = null +) diff --git a/app/src/main/java/com/swoosh/microblog/data/repository/PageRepository.kt b/app/src/main/java/com/swoosh/microblog/data/repository/PageRepository.kt new file mode 100644 index 0000000..5fa6d50 --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/data/repository/PageRepository.kt @@ -0,0 +1,81 @@ +package com.swoosh.microblog.data.repository + +import android.content.Context +import com.swoosh.microblog.data.AccountManager +import com.swoosh.microblog.data.api.ApiClient +import com.swoosh.microblog.data.api.GhostApiService +import com.swoosh.microblog.data.model.GhostPage +import com.swoosh.microblog.data.model.PageWrapper +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class PageRepository(private val context: Context) { + + private val accountManager = AccountManager(context) + + private fun getApi(): GhostApiService { + val account = accountManager.getActiveAccount() + ?: throw IllegalStateException("No active account configured") + return ApiClient.getService(account.blogUrl) { account.apiKey } + } + + fun getBlogUrl(): String? { + return accountManager.getActiveAccount()?.blogUrl + } + + suspend fun fetchPages(): Result> = + withContext(Dispatchers.IO) { + try { + val response = getApi().getPages() + if (response.isSuccessful) { + Result.success(response.body()!!.pages) + } else { + Result.failure(Exception("API error ${response.code()}: ${response.errorBody()?.string()}")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun createPage(page: GhostPage): Result = + withContext(Dispatchers.IO) { + try { + val response = getApi().createPage(PageWrapper(listOf(page))) + if (response.isSuccessful) { + Result.success(response.body()!!.pages.first()) + } else { + Result.failure(Exception("Create failed ${response.code()}: ${response.errorBody()?.string()}")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun updatePage(id: String, page: GhostPage): Result = + withContext(Dispatchers.IO) { + try { + val response = getApi().updatePage(id, PageWrapper(listOf(page))) + if (response.isSuccessful) { + Result.success(response.body()!!.pages.first()) + } else { + Result.failure(Exception("Update failed ${response.code()}: ${response.errorBody()?.string()}")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun deletePage(id: String): Result = + withContext(Dispatchers.IO) { + try { + val response = getApi().deletePage(id) + if (response.isSuccessful) { + Result.success(Unit) + } else { + Result.failure(Exception("Delete failed ${response.code()}")) + } + } catch (e: Exception) { + Result.failure(e) + } + } +} diff --git a/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt b/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt index bb4c93f..8325ee4 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt @@ -29,6 +29,7 @@ import com.swoosh.microblog.ui.composer.ComposerScreen import com.swoosh.microblog.ui.composer.ComposerViewModel import com.swoosh.microblog.ui.detail.DetailScreen import com.swoosh.microblog.ui.feed.FeedScreen +import com.swoosh.microblog.ui.pages.PagesScreen import com.swoosh.microblog.ui.feed.FeedViewModel import com.swoosh.microblog.ui.preview.PreviewScreen import com.swoosh.microblog.ui.settings.SettingsScreen @@ -46,6 +47,7 @@ object Routes { const val STATS = "stats" const val PREVIEW = "preview" const val ADD_ACCOUNT = "add_account" + const val PAGES = "pages" } data class BottomNavItem( @@ -255,6 +257,9 @@ fun SwooshNavGraph( navController.navigate(Routes.SETUP) { popUpTo(0) { inclusive = true } } + }, + onNavigateToPages = { + navController.navigate(Routes.PAGES) } ) } @@ -300,6 +305,18 @@ fun SwooshNavGraph( } ) } + + composable( + Routes.PAGES, + enterTransition = { slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(250)) + fadeIn(tween(200)) }, + exitTransition = { fadeOut(tween(150)) }, + popEnterTransition = { fadeIn(tween(200)) }, + popExitTransition = { slideOutHorizontally(targetOffsetX = { it }, animationSpec = tween(200)) + fadeOut(tween(150)) } + ) { + PagesScreen( + onBack = { navController.popBackStack() } + ) + } } } } diff --git a/app/src/main/java/com/swoosh/microblog/ui/pages/PagesScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/pages/PagesScreen.kt new file mode 100644 index 0000000..6304b1d --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/ui/pages/PagesScreen.kt @@ -0,0 +1,393 @@ +package com.swoosh.microblog.ui.pages + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.swoosh.microblog.data.MobiledocBuilder +import com.swoosh.microblog.data.model.GhostPage +import com.swoosh.microblog.ui.components.ConfirmationDialog + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PagesScreen( + onBack: () -> Unit, + viewModel: PagesViewModel = viewModel() +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + if (uiState.isEditing || uiState.isCreating) { + PageEditorScreen( + page = uiState.editingPage, + isCreating = uiState.isCreating, + isLoading = uiState.isLoading, + error = uiState.error, + blogUrl = viewModel.getBlogUrl(), + onSave = { title, content, slug, status -> + viewModel.savePage(title, content, slug, status) + }, + onUpdate = { id, page -> + viewModel.updatePage(id, page) + }, + onCancel = { viewModel.cancelEditing() } + ) + } else { + PagesListScreen( + pages = uiState.pages, + isLoading = uiState.isLoading, + error = uiState.error, + onBack = onBack, + onCreatePage = { viewModel.startCreating() }, + onEditPage = { page -> viewModel.startEditing(page) }, + onDeletePage = { id -> viewModel.deletePage(id) }, + onRefresh = { viewModel.loadPages() } + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +private fun PagesListScreen( + pages: List, + isLoading: Boolean, + error: String?, + onBack: () -> Unit, + onCreatePage: () -> Unit, + onEditPage: (GhostPage) -> Unit, + onDeletePage: (String) -> Unit, + onRefresh: () -> Unit +) { + var deletePageId by remember { mutableStateOf(null) } + var deletePageTitle by remember { mutableStateOf("") } + var expandedMenuPageId by remember { mutableStateOf(null) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Pages") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") + } + }, + actions = { + IconButton(onClick = onCreatePage) { + Icon(Icons.Default.Add, "New page") + } + } + ) + } + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + if (isLoading && pages.isEmpty()) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center) + ) + } else if (pages.isEmpty()) { + Text( + text = "No pages yet.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.align(Alignment.Center) + ) + } else { + LazyColumn( + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(pages, key = { it.id ?: it.hashCode() }) { page -> + Box { + OutlinedCard( + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { onEditPage(page) }, + onLongClick = { expandedMenuPageId = page.id } + ) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = page.title ?: "Untitled", + style = MaterialTheme.typography.titleMedium + ) + if (!page.slug.isNullOrBlank()) { + Text( + text = "/${page.slug}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Spacer(modifier = Modifier.height(8.dp)) + StatusChip(status = page.status ?: "draft") + } + } + + DropdownMenu( + expanded = expandedMenuPageId == page.id, + onDismissRequest = { expandedMenuPageId = null } + ) { + DropdownMenuItem( + text = { Text("Edit") }, + onClick = { + expandedMenuPageId = null + onEditPage(page) + } + ) + DropdownMenuItem( + text = { Text("Delete") }, + onClick = { + expandedMenuPageId = null + deletePageId = page.id + deletePageTitle = page.title ?: "Untitled" + } + ) + } + } + } + } + } + + if (error != null) { + Snackbar( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(16.dp), + action = { + TextButton(onClick = onRefresh) { Text("Retry") } + } + ) { + Text(error) + } + } + } + } + + if (deletePageId != null) { + ConfirmationDialog( + title = "Delete Page?", + message = "Delete \"$deletePageTitle\"? This cannot be undone.", + confirmLabel = "Delete", + onConfirm = { + deletePageId?.let { onDeletePage(it) } + deletePageId = null + }, + onDismiss = { deletePageId = null } + ) + } +} + +@Composable +private fun StatusChip(status: String) { + val (color, label) = when (status) { + "published" -> Color(0xFF4CAF50) to "Published" + else -> Color(0xFF9C27B0) to "Draft" + } + AssistChip( + onClick = {}, + label = { Text(label, style = MaterialTheme.typography.labelSmall) }, + colors = AssistChipDefaults.assistChipColors( + labelColor = color + ), + border = BorderStroke(1.dp, color.copy(alpha = 0.5f)) + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PageEditorScreen( + page: GhostPage?, + isCreating: Boolean, + isLoading: Boolean, + error: String?, + blogUrl: String?, + onSave: (title: String, content: String, slug: String?, status: String) -> Unit, + onUpdate: (id: String, page: GhostPage) -> Unit, + onCancel: () -> Unit +) { + val context = LocalContext.current + val isNew = isCreating && page == null + + var title by remember(page) { mutableStateOf(page?.title ?: "") } + var content by remember(page) { mutableStateOf(page?.plaintext ?: "") } + var slug by remember(page) { mutableStateOf(page?.slug ?: "") } + var selectedStatus by remember(page) { + mutableStateOf(if (page?.status == "published") "published" else "draft") + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(if (isNew) "New page" else "Edit page") }, + navigationIcon = { + IconButton(onClick = onCancel) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, "Cancel") + } + }, + actions = { + IconButton( + onClick = { + if (isNew) { + onSave(title, content, slug.takeIf { it.isNotBlank() }, selectedStatus) + } else { + val mobiledoc = MobiledocBuilder.build(content, null, null, null) + val updatedPage = GhostPage( + title = title, + mobiledoc = mobiledoc, + slug = slug.takeIf { it.isNotBlank() }, + status = selectedStatus, + updated_at = page?.updated_at + ) + page?.id?.let { onUpdate(it, updatedPage) } + } + }, + enabled = title.isNotBlank() && !isLoading + ) { + Icon(Icons.Default.Check, "Save") + } + } + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = title, + onValueChange = { title = it }, + label = { Text("Title *") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + OutlinedTextField( + value = content, + onValueChange = { content = it }, + label = { Text("Content") }, + modifier = Modifier.fillMaxWidth(), + minLines = 8 + ) + + OutlinedTextField( + value = slug, + onValueChange = { slug = it }, + label = { Text("Slug (optional)") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + // Status radio group + Text("Status", style = MaterialTheme.typography.titleSmall) + Column(modifier = Modifier.selectableGroup()) { + Row( + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = selectedStatus == "draft", + onClick = { selectedStatus = "draft" }, + role = Role.RadioButton + ) + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = selectedStatus == "draft", + onClick = null + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Draft") + } + Row( + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = selectedStatus == "published", + onClick = { selectedStatus = "published" }, + role = Role.RadioButton + ) + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = selectedStatus == "published", + onClick = null + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Publish") + } + } + + // Open in browser for published pages + if (!isNew && page?.status == "published" && !page.slug.isNullOrBlank() && blogUrl != null) { + val pageUrl = "${blogUrl.removeSuffix("/")}/${page.slug}/" + OutlinedButton( + onClick = { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(pageUrl)) + context.startActivity(intent) + }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Open in browser") + } + } + + // Revert to draft for published pages + if (!isNew && page?.status == "published") { + TextButton( + onClick = { selectedStatus = "draft" }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Revert to draft") + } + } + + if (isLoading) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + + if (error != null) { + Text( + text = error, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + } + } +} diff --git a/app/src/main/java/com/swoosh/microblog/ui/pages/PagesViewModel.kt b/app/src/main/java/com/swoosh/microblog/ui/pages/PagesViewModel.kt new file mode 100644 index 0000000..e3de283 --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/ui/pages/PagesViewModel.kt @@ -0,0 +1,111 @@ +package com.swoosh.microblog.ui.pages + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.swoosh.microblog.data.MobiledocBuilder +import com.swoosh.microblog.data.model.GhostPage +import com.swoosh.microblog.data.repository.PageRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class PagesViewModel(application: Application) : AndroidViewModel(application) { + + private val repository = PageRepository(application) + + private val _uiState = MutableStateFlow(PagesUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadPages() + } + + fun loadPages() { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, error = null) } + repository.fetchPages().fold( + onSuccess = { pages -> + _uiState.update { it.copy(pages = pages, isLoading = false) } + }, + onFailure = { e -> + _uiState.update { it.copy(isLoading = false, error = e.message) } + } + ) + } + } + + fun savePage(title: String, content: String, slug: String?, status: String) { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, error = null) } + val mobiledoc = MobiledocBuilder.build(content, null, null, null) + val page = GhostPage( + title = title, + mobiledoc = mobiledoc, + slug = slug?.takeIf { it.isNotBlank() }, + status = status + ) + repository.createPage(page).fold( + onSuccess = { + _uiState.update { it.copy(isEditing = false, isCreating = false, editingPage = null) } + loadPages() + }, + onFailure = { e -> + _uiState.update { it.copy(isLoading = false, error = e.message) } + } + ) + } + } + + fun updatePage(id: String, page: GhostPage) { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, error = null) } + repository.updatePage(id, page).fold( + onSuccess = { + _uiState.update { it.copy(isEditing = false, isCreating = false, editingPage = null) } + loadPages() + }, + onFailure = { e -> + _uiState.update { it.copy(isLoading = false, error = e.message) } + } + ) + } + } + + fun deletePage(id: String) { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, error = null) } + repository.deletePage(id).fold( + onSuccess = { loadPages() }, + onFailure = { e -> + _uiState.update { it.copy(isLoading = false, error = e.message) } + } + ) + } + } + + fun startEditing(page: GhostPage) { + _uiState.update { it.copy(editingPage = page, isEditing = true, isCreating = false) } + } + + fun startCreating() { + _uiState.update { it.copy(editingPage = null, isEditing = false, isCreating = true) } + } + + fun cancelEditing() { + _uiState.update { it.copy(editingPage = null, isEditing = false, isCreating = false) } + } + + fun getBlogUrl(): String? = repository.getBlogUrl() +} + +data class PagesUiState( + val pages: List = emptyList(), + val isLoading: Boolean = false, + val error: String? = null, + val editingPage: GhostPage? = null, + val isEditing: Boolean = false, + val isCreating: Boolean = false +) diff --git a/app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt index 28e7248..e80e9f8 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt @@ -2,6 +2,7 @@ package com.swoosh.microblog.ui.settings import androidx.compose.animation.* import androidx.compose.animation.core.* +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -30,7 +31,8 @@ import com.swoosh.microblog.ui.theme.ThemeViewModel fun SettingsScreen( onBack: () -> Unit, onLogout: () -> Unit, - themeViewModel: ThemeViewModel? = null + themeViewModel: ThemeViewModel? = null, + onNavigateToPages: () -> Unit = {} ) { val context = LocalContext.current val accountManager = remember { AccountManager(context) } @@ -128,6 +130,30 @@ fun SettingsScreen( HorizontalDivider() Spacer(modifier = Modifier.height(16.dp)) + // Static Pages + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onNavigateToPages) + .padding(vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Static Pages", + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = "\u203A", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(16.dp)) + // Disconnect current account OutlinedButton( onClick = { showDisconnectDialog = true }, diff --git a/app/src/test/java/com/swoosh/microblog/data/model/PageModelsTest.kt b/app/src/test/java/com/swoosh/microblog/data/model/PageModelsTest.kt new file mode 100644 index 0000000..e3a704c --- /dev/null +++ b/app/src/test/java/com/swoosh/microblog/data/model/PageModelsTest.kt @@ -0,0 +1,162 @@ +package com.swoosh.microblog.data.model + +import com.google.gson.Gson +import org.junit.Assert.* +import org.junit.Test + +class PageModelsTest { + + private val gson = Gson() + + // --- GhostPage defaults --- + + @Test + fun `GhostPage all fields default to null`() { + val page = GhostPage() + assertNull(page.id) + assertNull(page.title) + assertNull(page.slug) + assertNull(page.url) + assertNull(page.html) + assertNull(page.plaintext) + assertNull(page.mobiledoc) + assertNull(page.status) + assertNull(page.feature_image) + assertNull(page.custom_excerpt) + assertNull(page.created_at) + assertNull(page.updated_at) + assertNull(page.published_at) + } + + @Test + fun `GhostPage stores all fields correctly`() { + val page = GhostPage( + id = "page-1", + title = "About", + slug = "about", + url = "https://blog.example.com/about/", + html = "

About us

", + plaintext = "About us", + mobiledoc = """{"version":"0.3.1"}""", + status = "published", + feature_image = "https://blog.example.com/img.jpg", + custom_excerpt = "Learn more about us", + created_at = "2024-01-01T00:00:00.000Z", + updated_at = "2024-06-15T12:00:00.000Z", + published_at = "2024-01-02T00:00:00.000Z" + ) + assertEquals("page-1", page.id) + assertEquals("About", page.title) + assertEquals("about", page.slug) + assertEquals("https://blog.example.com/about/", page.url) + assertEquals("

About us

", page.html) + assertEquals("About us", page.plaintext) + assertEquals("published", page.status) + assertEquals("https://blog.example.com/img.jpg", page.feature_image) + assertEquals("Learn more about us", page.custom_excerpt) + assertEquals("2024-01-01T00:00:00.000Z", page.created_at) + assertEquals("2024-06-15T12:00:00.000Z", page.updated_at) + assertEquals("2024-01-02T00:00:00.000Z", page.published_at) + } + + // --- GSON serialization --- + + @Test + fun `GhostPage serializes to JSON correctly`() { + val page = GhostPage( + id = "abc123", + title = "Contact", + slug = "contact", + status = "published" + ) + val json = gson.toJson(page) + assertTrue(json.contains("\"id\":\"abc123\"")) + assertTrue(json.contains("\"title\":\"Contact\"")) + assertTrue(json.contains("\"slug\":\"contact\"")) + assertTrue(json.contains("\"status\":\"published\"")) + } + + @Test + fun `GhostPage deserializes from JSON correctly`() { + val json = """{"id":"xyz","title":"FAQ","slug":"faq","status":"draft"}""" + val page = gson.fromJson(json, GhostPage::class.java) + assertEquals("xyz", page.id) + assertEquals("FAQ", page.title) + assertEquals("faq", page.slug) + assertEquals("draft", page.status) + } + + @Test + fun `GhostPage deserializes with missing optional fields`() { + val json = """{"id":"test"}""" + val page = gson.fromJson(json, GhostPage::class.java) + assertEquals("test", page.id) + assertNull(page.title) + assertNull(page.slug) + assertNull(page.html) + assertNull(page.status) + } + + // --- PagesResponse --- + + @Test + fun `PagesResponse deserializes with pages and pagination`() { + val json = """{ + "pages": [{"id": "1", "title": "About"}, {"id": "2", "title": "Contact"}], + "meta": {"pagination": {"page": 1, "limit": 15, "pages": 1, "total": 2, "next": null, "prev": null}} + }""" + val response = gson.fromJson(json, PagesResponse::class.java) + assertEquals(2, response.pages.size) + assertEquals("1", response.pages[0].id) + assertEquals("About", response.pages[0].title) + assertEquals("2", response.pages[1].id) + assertEquals("Contact", response.pages[1].title) + assertEquals(1, response.meta?.pagination?.page) + assertEquals(2, response.meta?.pagination?.total) + assertNull(response.meta?.pagination?.next) + } + + @Test + fun `PagesResponse deserializes with empty pages list`() { + val json = """{"pages": [], "meta": null}""" + val response = gson.fromJson(json, PagesResponse::class.java) + assertTrue(response.pages.isEmpty()) + assertNull(response.meta) + } + + // --- PageWrapper --- + + @Test + fun `PageWrapper wraps pages for API request`() { + val wrapper = PageWrapper(listOf(GhostPage(title = "New Page", status = "draft"))) + val json = gson.toJson(wrapper) + assertTrue(json.contains("\"pages\"")) + assertTrue(json.contains("\"New Page\"")) + } + + @Test + fun `PageWrapper serializes single page correctly`() { + val page = GhostPage( + title = "About Us", + slug = "about-us", + status = "published", + mobiledoc = """{"version":"0.3.1","atoms":[],"cards":[],"markups":[],"sections":[[1,"p",[[0,[],0,"Welcome"]]]]}""" + ) + val wrapper = PageWrapper(listOf(page)) + val json = gson.toJson(wrapper) + assertTrue(json.contains("\"title\":\"About Us\"")) + assertTrue(json.contains("\"slug\":\"about-us\"")) + assertTrue(json.contains("\"status\":\"published\"")) + } + + @Test + fun `GhostPage updated_at is preserved for PUT requests`() { + val page = GhostPage( + id = "page-1", + title = "Updated Title", + updated_at = "2024-06-15T12:00:00.000Z" + ) + val json = gson.toJson(page) + assertTrue(json.contains("\"updated_at\":\"2024-06-15T12:00:00.000Z\"")) + } +}