From c5596845c180ba2d525080b25df1908d9e475528 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 21 Jul 2026 15:55:48 -0500 Subject: [PATCH] =?UTF-8?q?feat(admin):=20staff=20content=20=E2=80=94=20ne?= =?UTF-8?q?ws=20posts=20+=20wiki=20taxonomy=20(M10=20Phase=203,=20part=202?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second staff group over the existing /admin routes (any staff role; bearer-authed, role re-checked every request). AdminApi/AdminDto/AdminRepository gain posts (list/create/publish-toggle/delete) and wiki taxonomy (list categories + tags, create/delete category). AdminContentScreen is a two-tab screen (Posts | Wiki) with create dialogs; the CMS block/hero editor stays out of scope. Admin wiki DTOs are prefixed (AdminWikiCategoryDto/AdminWikiTagDto) to avoid colliding with the public wiki DTOs. Verified on emulator against the dev backend: posts list with published/draft pills; publish/unpublish flips the DB row with live reload; create a news post; create + delete a wiki category (confirmed in MariaDB). assembleDebug + lint green. Co-Authored-By: Claude --- .../com/runicgateway/app/data/api/AdminApi.kt | 37 +++ .../runicgateway/app/data/api/dto/AdminDto.kt | 70 +++++ .../app/data/repository/AdminRepository.kt | 36 +++ .../java/com/runicgateway/app/ui/RunicApp.kt | 6 +- .../app/ui/admin/AdminContentScreen.kt | 292 ++++++++++++++++++ .../app/ui/admin/AdminContentViewModel.kt | 140 +++++++++ .../runicgateway/app/ui/navigation/Menu.kt | 1 + app/src/main/res/values/strings.xml | 31 ++ 8 files changed, 612 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/runicgateway/app/ui/admin/AdminContentScreen.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/admin/AdminContentViewModel.kt diff --git a/app/src/main/java/com/runicgateway/app/data/api/AdminApi.kt b/app/src/main/java/com/runicgateway/app/data/api/AdminApi.kt index 05005b1..8a32101 100644 --- a/app/src/main/java/com/runicgateway/app/data/api/AdminApi.kt +++ b/app/src/main/java/com/runicgateway/app/data/api/AdminApi.kt @@ -4,11 +4,22 @@ package com.runicgateway.app.data.api import com.runicgateway.app.data.api.dto.AdminDashboardDto +import com.runicgateway.app.data.api.dto.AdminPostDto +import com.runicgateway.app.data.api.dto.PostCreateRequest +import com.runicgateway.app.data.api.dto.PublishRequest import com.runicgateway.app.data.api.dto.SiteModeRequest import com.runicgateway.app.data.api.dto.SiteModeStateDto +import com.runicgateway.app.data.api.dto.AdminWikiCategoryDto +import com.runicgateway.app.data.api.dto.WikiCategoryRequest +import com.runicgateway.app.data.api.dto.AdminWikiTagDto +import retrofit2.Response import retrofit2.http.Body +import retrofit2.http.DELETE import retrofit2.http.GET +import retrofit2.http.PATCH import retrofit2.http.PUT +import retrofit2.http.POST +import retrofit2.http.Path /** * The M10 staff-operations surface over `/api/v1/admin/…` (PLAN.md §1, §6.4). On @@ -28,4 +39,30 @@ interface AdminApi { /** `PUT /admin/site-mode` — switch live/maintenance (admin only; 403 otherwise). */ @PUT("api/v1/admin/site-mode") suspend fun setSiteMode(@Body body: SiteModeRequest): SiteModeStateDto + + // ── Content: news posts (any staff role) ────────────────────────────── + @GET("api/v1/admin/posts") + suspend fun posts(): List + + @POST("api/v1/admin/posts") + suspend fun createPost(@Body body: PostCreateRequest): AdminPostDto + + @PATCH("api/v1/admin/posts/{id}/publish") + suspend fun publishPost(@Path("id") id: Long, @Body body: PublishRequest): AdminPostDto + + @DELETE("api/v1/admin/posts/{id}") + suspend fun deletePost(@Path("id") id: Long): Response + + // ── Content: wiki taxonomy (any staff role) ─────────────────────────── + @GET("api/v1/admin/wiki/categories") + suspend fun wikiCategories(): List + + @POST("api/v1/admin/wiki/categories") + suspend fun createWikiCategory(@Body body: WikiCategoryRequest): AdminWikiCategoryDto + + @DELETE("api/v1/admin/wiki/categories/{id}") + suspend fun deleteWikiCategory(@Path("id") id: Long): Response + + @GET("api/v1/admin/wiki/tags") + suspend fun wikiTags(): List } diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/AdminDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/AdminDto.kt index 6a352e1..d0c0fa2 100644 --- a/app/src/main/java/com/runicgateway/app/data/api/dto/AdminDto.kt +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/AdminDto.kt @@ -56,3 +56,73 @@ data class SiteModeStateDto( @SerialName("changed_at") val changedAt: String? = null, @SerialName("changed_by") val changedBy: String? = null, ) + +// ── Content: news posts ─────────────────────────────────────────────────── + +/** + * A post row from `GET /admin/posts` (all posts, incl. unpublished — unlike the + * public feed). `published` is a 0/1 flag (MariaDB tinyint), exposed as [isPublished]. + */ +@Serializable +data class AdminPostDto( + val id: Long, + val category: String = "", + val title: String = "", + val slug: String? = null, + val excerpt: String? = null, + val body: String? = null, + @SerialName("image_url") val imageUrl: String? = null, + val published: Int = 0, + @SerialName("published_at") val publishedAt: String? = null, + @SerialName("created_at") val createdAt: String? = null, +) { + val isPublished: Boolean get() = published != 0 +} + +/** `POST/PUT /admin/posts` body. `category` is a URL category the backend maps + * (news | five-on-friday | newsletter | screenshots). */ +@Serializable +data class PostCreateRequest( + val category: String, + val title: String, + val excerpt: String? = null, + val body: String? = null, + @SerialName("image_url") val imageUrl: String? = null, + val published: Boolean = false, +) + +/** `PATCH /admin/posts/:id/publish` body. */ +@Serializable +data class PublishRequest(val published: Boolean) + +// ── Content: wiki taxonomy ──────────────────────────────────────────────── + +/** A wiki category from `GET /admin/wiki/categories` (with page counts). */ +@Serializable +data class AdminWikiCategoryDto( + val id: Long, + val slug: String = "", + val title: String = "", + val description: String? = null, + @SerialName("sort_order") val sortOrder: Int? = null, + @SerialName("page_count") val pageCount: Int? = null, + @SerialName("published_count") val publishedCount: Int? = null, +) + +/** `POST /admin/wiki/categories` body. */ +@Serializable +data class WikiCategoryRequest( + val slug: String, + val title: String, + val description: String? = null, + @SerialName("sort_order") val sortOrder: Int? = null, +) + +/** A wiki tag from `GET /admin/wiki/tags` (tags derive from pages; read-only here). */ +@Serializable +data class AdminWikiTagDto( + val id: Long, + val slug: String = "", + val label: String = "", + @SerialName("published_count") val publishedCount: Int? = null, +) diff --git a/app/src/main/java/com/runicgateway/app/data/repository/AdminRepository.kt b/app/src/main/java/com/runicgateway/app/data/repository/AdminRepository.kt index 554ccd0..cefd3e8 100644 --- a/app/src/main/java/com/runicgateway/app/data/repository/AdminRepository.kt +++ b/app/src/main/java/com/runicgateway/app/data/repository/AdminRepository.kt @@ -7,8 +7,16 @@ import com.runicgateway.app.core.result.ApiResult import com.runicgateway.app.core.result.safeApiCall import com.runicgateway.app.data.api.AdminApi import com.runicgateway.app.data.api.dto.AdminDashboardDto +import com.runicgateway.app.data.api.dto.AdminPostDto +import com.runicgateway.app.data.api.dto.PostCreateRequest +import com.runicgateway.app.data.api.dto.PublishRequest import com.runicgateway.app.data.api.dto.SiteModeRequest import com.runicgateway.app.data.api.dto.SiteModeStateDto +import com.runicgateway.app.data.api.dto.AdminWikiCategoryDto +import com.runicgateway.app.data.api.dto.WikiCategoryRequest +import com.runicgateway.app.data.api.dto.AdminWikiTagDto +import retrofit2.HttpException +import retrofit2.Response import javax.inject.Inject import javax.inject.Singleton @@ -27,4 +35,32 @@ class AdminRepository @Inject constructor( suspend fun setSiteMode(mode: String): ApiResult = safeApiCall { api.setSiteMode(SiteModeRequest(mode)) } + + // ── Content: news posts ─────────────────────────────────────────────── + suspend fun posts(): ApiResult> = safeApiCall { api.posts() } + + suspend fun createPost(body: PostCreateRequest): ApiResult = + safeApiCall { api.createPost(body) } + + suspend fun setPostPublished(id: Long, published: Boolean): ApiResult = + safeApiCall { api.publishPost(id, PublishRequest(published)) } + + suspend fun deletePost(id: Long): ApiResult = safeApiCall { api.deletePost(id).requireOk() } + + // ── Content: wiki taxonomy ──────────────────────────────────────────── + suspend fun wikiCategories(): ApiResult> = safeApiCall { api.wikiCategories() } + + suspend fun createWikiCategory(body: WikiCategoryRequest): ApiResult = + safeApiCall { api.createWikiCategory(body) } + + suspend fun deleteWikiCategory(id: Long): ApiResult = + safeApiCall { api.deleteWikiCategory(id).requireOk() } + + suspend fun wikiTags(): ApiResult> = safeApiCall { api.wikiTags() } + + /** Turn a bodyless [Response] into a thrown [HttpException] on a non-2xx, so + * [safeApiCall] can fold it into an [ApiResult.HttpError] like every other call. */ + private fun Response.requireOk() { + if (!isSuccessful) throw HttpException(this) + } } diff --git a/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt index 15fd102..95fa12d 100644 --- a/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt +++ b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt @@ -59,6 +59,7 @@ import com.runicgateway.app.ui.navigation.Routes import com.runicgateway.app.ui.navigation.visibleEntries import com.runicgateway.app.ui.news.NewsScreen import com.runicgateway.app.ui.news.PostScreen +import com.runicgateway.app.ui.admin.AdminContentScreen import com.runicgateway.app.ui.admin.AdminDashboardScreen import com.runicgateway.app.ui.notifications.NotificationsScreen import com.runicgateway.app.ui.page.PageScreen @@ -82,7 +83,7 @@ private val TOP_LEVEL_ROUTES = setOf( Routes.HOME, Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.CONTACT, Routes.PAGE, Routes.ACCOUNT, Routes.NOTIFICATIONS, Routes.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES, - Routes.ADMIN_DASHBOARD, + Routes.ADMIN_DASHBOARD, Routes.ADMIN_CONTENT, ) /** @@ -372,6 +373,9 @@ private fun RunicNavHost( AdminDashboardScreen(isAdmin = (session as? Session.SignedIn)?.user?.isAdmin == true) } } + composable(Routes.ADMIN_CONTENT) { + StaffGate(session, navController) { AdminContentScreen() } + } } } diff --git a/app/src/main/java/com/runicgateway/app/ui/admin/AdminContentScreen.kt b/app/src/main/java/com/runicgateway/app/ui/admin/AdminContentScreen.kt new file mode 100644 index 0000000..e230e1d --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/admin/AdminContentScreen.kt @@ -0,0 +1,292 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.admin + +import androidx.compose.foundation.layout.Arrangement +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.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.runicgateway.app.R +import com.runicgateway.app.data.api.dto.AdminPostDto +import com.runicgateway.app.data.api.dto.AdminWikiCategoryDto +import com.runicgateway.app.data.api.dto.AdminWikiTagDto +import com.runicgateway.app.ui.UiState +import com.runicgateway.app.ui.components.ErrorView +import com.runicgateway.app.ui.components.LoadingView +import com.runicgateway.app.ui.components.PillTone +import com.runicgateway.app.ui.components.StatusPill + +/** + * The staff content screen (PLAN.md §1, M10): news posts and wiki taxonomy, in two + * tabs. Create/publish/delete over the existing `/admin/posts` + `/admin/wiki/…` + * routes; the CMS block/hero editor stays out of scope. Any staff role; the server + * re-checks on every call. + */ +@Composable +fun AdminContentScreen( + modifier: Modifier = Modifier, + viewModel: AdminContentViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + var tab by rememberSaveable { mutableIntStateOf(0) } + var showNewPost by rememberSaveable { mutableStateOf(false) } + var showNewCategory by rememberSaveable { mutableStateOf(false) } + + Column(modifier.fillMaxSize()) { + TabRow(selectedTabIndex = tab) { + Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text(stringResource(R.string.admin_content_tab_posts)) }) + Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text(stringResource(R.string.admin_content_tab_wiki)) }) + } + + state.feedback?.let { + Text( + text = stringResource(it.messageRes), + style = MaterialTheme.typography.bodySmall, + color = if (it.ok) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.error, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp), + ) + } + + when (tab) { + 0 -> PostsTab( + state = state.posts, + busy = state.busy, + onNew = { showNewPost = true }, + onToggle = viewModel::togglePublish, + onDelete = viewModel::deletePost, + onRetry = viewModel::loadPosts, + ) + else -> WikiTab( + state = state.categories, + tags = state.tags, + busy = state.busy, + onNew = { showNewCategory = true }, + onDelete = viewModel::deleteCategory, + onRetry = viewModel::loadWiki, + ) + } + } + + if (showNewPost) { + NewPostDialog( + categories = viewModel.postCategories, + onDismiss = { showNewPost = false }, + onCreate = { cat, title, excerpt, body, published -> + viewModel.createPost(cat, title, excerpt, body, published) + showNewPost = false + }, + ) + } + if (showNewCategory) { + NewCategoryDialog( + onDismiss = { showNewCategory = false }, + onCreate = { slug, title, desc, sort -> + viewModel.createCategory(slug, title, desc, sort) + showNewCategory = false + }, + ) + } +} + +@Composable +private fun PostsTab( + state: UiState>, + busy: Boolean, + onNew: () -> Unit, + onToggle: (AdminPostDto) -> Unit, + onDelete: (Long) -> Unit, + onRetry: () -> Unit, +) { + when (state) { + is UiState.Loading -> LoadingView() + is UiState.Error -> ErrorView(state.kind, onRetry = onRetry) + is UiState.Success -> LazyColumn(Modifier.fillMaxSize().padding(16.dp)) { + item { + OutlinedButton(onClick = onNew, enabled = !busy, modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) { + Text(stringResource(R.string.admin_content_new_post)) + } + } + items(state.data, key = { it.id }) { post -> + Card(Modifier.fillMaxWidth().padding(vertical = 6.dp)) { + Column(Modifier.padding(12.dp)) { + Text(post.title, style = MaterialTheme.typography.bodyLarge) + Spacer(Modifier.height(4.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + StatusPill( + text = if (post.isPublished) stringResource(R.string.admin_content_published) + else stringResource(R.string.admin_content_draft), + tone = if (post.isPublished) PillTone.Success else PillTone.Neutral, + ) + Spacer(Modifier.width(8.dp)) + Text(post.category, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Row(Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.End) { + TextButton(onClick = { onToggle(post) }, enabled = !busy) { + Text( + stringResource( + if (post.isPublished) R.string.admin_content_unpublish else R.string.admin_content_publish, + ), + ) + } + TextButton(onClick = { onDelete(post.id) }, enabled = !busy) { + Text(stringResource(R.string.admin_content_delete), color = MaterialTheme.colorScheme.error) + } + } + } + } + } + } + } +} + +@Composable +private fun WikiTab( + state: UiState>, + tags: List, + busy: Boolean, + onNew: () -> Unit, + onDelete: (Long) -> Unit, + onRetry: () -> Unit, +) { + when (state) { + is UiState.Loading -> LoadingView() + is UiState.Error -> ErrorView(state.kind, onRetry = onRetry) + is UiState.Success -> LazyColumn(Modifier.fillMaxSize().padding(16.dp)) { + item { + OutlinedButton(onClick = onNew, enabled = !busy, modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) { + Text(stringResource(R.string.admin_content_new_category)) + } + } + items(state.data, key = { it.id }) { cat -> + Card(Modifier.fillMaxWidth().padding(vertical = 6.dp)) { + Column(Modifier.padding(12.dp)) { + Text(cat.title, style = MaterialTheme.typography.bodyLarge) + Text( + text = stringResource(R.string.admin_content_cat_meta, cat.slug, cat.pageCount ?: 0), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row(Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.End) { + TextButton(onClick = { onDelete(cat.id) }, enabled = !busy) { + Text(stringResource(R.string.admin_content_delete), color = MaterialTheme.colorScheme.error) + } + } + } + } + } + if (tags.isNotEmpty()) { + item { + HorizontalDivider(Modifier.padding(vertical = 12.dp)) + Text( + stringResource(R.string.admin_content_tags, tags.joinToString(", ") { it.label }), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +@Composable +private fun NewPostDialog( + categories: List, + onDismiss: () -> Unit, + onCreate: (category: String, title: String, excerpt: String, body: String, published: Boolean) -> Unit, +) { + var category by rememberSaveable { mutableStateOf(categories.first()) } + var title by rememberSaveable { mutableStateOf("") } + var excerpt by rememberSaveable { mutableStateOf("") } + var body by rememberSaveable { mutableStateOf("") } + var published by rememberSaveable { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton(onClick = { onCreate(category, title, excerpt, body, published) }) { + Text(stringResource(R.string.admin_content_create)) + } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } }, + title = { Text(stringResource(R.string.admin_content_new_post)) }, + text = { + Column { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + categories.forEach { c -> + FilterChip(selected = category == c, onClick = { category = c }, label = { Text(c) }) + } + } + OutlinedTextField(value = title, onValueChange = { title = it }, singleLine = true, label = { Text(stringResource(R.string.admin_content_field_title)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) + OutlinedTextField(value = excerpt, onValueChange = { excerpt = it }, label = { Text(stringResource(R.string.admin_content_field_excerpt)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) + OutlinedTextField(value = body, onValueChange = { body = it }, label = { Text(stringResource(R.string.admin_content_field_body)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) + Row(Modifier.fillMaxWidth().padding(top = 8.dp), verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(R.string.admin_content_publish_now), modifier = Modifier.weight(1f)) + Switch(checked = published, onCheckedChange = { published = it }) + } + } + }, + ) +} + +@Composable +private fun NewCategoryDialog( + onDismiss: () -> Unit, + onCreate: (slug: String, title: String, description: String, sortOrder: Int?) -> Unit, +) { + var slug by rememberSaveable { mutableStateOf("") } + var title by rememberSaveable { mutableStateOf("") } + var description by rememberSaveable { mutableStateOf("") } + var sort by rememberSaveable { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton(onClick = { onCreate(slug, title, description, sort.toIntOrNull()) }) { + Text(stringResource(R.string.admin_content_create)) + } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } }, + title = { Text(stringResource(R.string.admin_content_new_category)) }, + text = { + Column { + OutlinedTextField(value = slug, onValueChange = { slug = it }, singleLine = true, label = { Text(stringResource(R.string.admin_content_field_slug)) }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(value = title, onValueChange = { title = it }, singleLine = true, label = { Text(stringResource(R.string.admin_content_field_title)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) + OutlinedTextField(value = description, onValueChange = { description = it }, label = { Text(stringResource(R.string.admin_content_field_description)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) + OutlinedTextField(value = sort, onValueChange = { sort = it.filter(Char::isDigit) }, singleLine = true, label = { Text(stringResource(R.string.admin_content_field_sort)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) + } + }, + ) +} diff --git a/app/src/main/java/com/runicgateway/app/ui/admin/AdminContentViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/admin/AdminContentViewModel.kt new file mode 100644 index 0000000..dbc967c --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/admin/AdminContentViewModel.kt @@ -0,0 +1,140 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.admin + +import androidx.annotation.StringRes +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.runicgateway.app.R +import com.runicgateway.app.core.result.ApiResult +import com.runicgateway.app.data.api.dto.AdminPostDto +import com.runicgateway.app.data.api.dto.PostCreateRequest +import com.runicgateway.app.data.api.dto.AdminWikiCategoryDto +import com.runicgateway.app.data.api.dto.WikiCategoryRequest +import com.runicgateway.app.data.api.dto.AdminWikiTagDto +import com.runicgateway.app.data.repository.AdminRepository +import com.runicgateway.app.ui.UiState +import com.runicgateway.app.ui.toUiState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Drives the staff content screen (PLAN.md §1, M10): news posts (list, create, + * publish/unpublish, delete) and wiki taxonomy (list categories/tags, create/delete + * category). Any staff role reaches these (`staffOnly`); the full CMS block/hero + * editor stays out of scope. Reads go through the typed [AdminRepository] (§7). + */ +@HiltViewModel +class AdminContentViewModel @Inject constructor( + private val admin: AdminRepository, +) : ViewModel() { + + /** The valid URL categories the backend maps (posts.model CATEGORY_MAP keys). */ + val postCategories = listOf("news", "five-on-friday", "newsletter", "screenshots") + + data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int) + + data class State( + val posts: UiState> = UiState.Loading, + val categories: UiState> = UiState.Loading, + val tags: List = emptyList(), + val busy: Boolean = false, + val feedback: Feedback? = null, + ) + + private val _state = MutableStateFlow(State()) + val state: StateFlow = _state.asStateFlow() + + init { + loadPosts() + loadWiki() + } + + fun clearFeedback() = _state.update { it.copy(feedback = null) } + + fun loadPosts() { + _state.update { it.copy(posts = UiState.Loading) } + viewModelScope.launch { _state.update { it.copy(posts = admin.posts().toUiState()) } } + } + + fun loadWiki() { + _state.update { it.copy(categories = UiState.Loading) } + viewModelScope.launch { + _state.update { it.copy(categories = admin.wikiCategories().toUiState()) } + when (val tags = admin.wikiTags()) { + is ApiResult.Ok -> _state.update { it.copy(tags = tags.data) } + else -> Unit // tags are secondary; leave the last list on a failure + } + } + } + + fun togglePublish(post: AdminPostDto) = mutate(onSuccess = ::loadPosts) { + admin.setPostPublished(post.id, !post.isPublished).asFeedback(R.string.admin_content_post_updated) + } + + fun deletePost(id: Long) = mutate(onSuccess = ::loadPosts) { + admin.deletePost(id).asFeedback(R.string.admin_content_post_deleted) + } + + fun createPost(category: String, title: String, excerpt: String, body: String, published: Boolean) { + if (title.isBlank()) { + _state.update { it.copy(feedback = Feedback(false, R.string.admin_content_title_required)) } + return + } + mutate(onSuccess = ::loadPosts) { + admin.createPost( + PostCreateRequest( + category = category, + title = title.trim(), + excerpt = excerpt.ifBlank { null }, + body = body.ifBlank { null }, + published = published, + ), + ).asFeedback(R.string.admin_content_post_created) + } + } + + fun createCategory(slug: String, title: String, description: String, sortOrder: Int?) { + if (slug.isBlank() || title.isBlank()) { + _state.update { it.copy(feedback = Feedback(false, R.string.admin_content_cat_fields_required)) } + return + } + mutate(onSuccess = ::loadWiki) { + admin.createWikiCategory( + WikiCategoryRequest(slug.trim(), title.trim(), description.ifBlank { null }, sortOrder), + ).asFeedback(R.string.admin_content_cat_created) + } + } + + fun deleteCategory(id: Long) = mutate(onSuccess = ::loadWiki) { + admin.deleteWikiCategory(id).asFeedback(R.string.admin_content_cat_deleted) + } + + // ── Shared mutation plumbing ────────────────────────────────────────── + + /** Run a write: set busy + clear feedback, then on completion set the feedback + * banner and, only if it succeeded, run [onSuccess] (a targeted reload). */ + private fun mutate(onSuccess: () -> Unit = {}, block: suspend () -> Feedback) { + if (_state.value.busy) return + _state.update { it.copy(busy = true, feedback = null) } + viewModelScope.launch { + val feedback = block() + if (feedback.ok) onSuccess() + _state.update { it.copy(busy = false, feedback = feedback) } + } + } + + /** Map an [ApiResult] to a [Feedback], with role/permission-aware failure copy. */ + private fun ApiResult<*>.asFeedback(@StringRes okRes: Int): Feedback = when (this) { + is ApiResult.Ok -> Feedback(true, okRes) + is ApiResult.HttpError -> + Feedback(false, if (status == 403) R.string.admin_forbidden else R.string.admin_action_failed) + is ApiResult.NetworkError -> Feedback(false, R.string.error_network) + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt b/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt index 9a2ab42..613b0bf 100644 --- a/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt +++ b/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt @@ -56,6 +56,7 @@ val APP_MENU: List = listOf( MenuEntry(Routes.PLAYER_HOUSES, R.string.menu_my_houses, MenuAccess.PLAYER), // Staff operations (§1, M10) — revealed for staff roles; the backend re-checks every call. MenuEntry(Routes.ADMIN_DASHBOARD, R.string.menu_admin_dashboard, MenuAccess.STAFF), + MenuEntry(Routes.ADMIN_CONTENT, R.string.menu_admin_content, MenuAccess.STAFF), ) /** diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4e31311..1a8e1b2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -68,6 +68,37 @@ Posts You don\'t have permission for that action. That action couldn\'t be completed. Please try again. + Cancel + + + Posts + Wiki + New post + New category + Create + Published + Draft + Publish + Unpublish + Delete + Publish now + Title + Excerpt + Body + Slug + Description + Sort order + + %1$s · %2$d pages + + Tags: %1$s + Post created. + Post updated. + Post deleted. + Category created. + Category deleted. + A title is required. + Slug and title are required. Sign in